From 227ac05d3a5b7b489f5aed76585437ac4354c685 Mon Sep 17 00:00:00 2001 From: none23 Date: Sun, 26 Jul 2026 17:36:17 +0400 Subject: [PATCH 1/6] Harden artifact publisher configuration --- package.json | 4 +- skills/codex-artifacts/scripts/publish.mjs | 189 +++++++++--------- .../scripts/publisher-core.mjs | 138 +++++++++++++ test/publisher-core.test.mjs | 125 ++++++++++++ 4 files changed, 357 insertions(+), 99 deletions(-) create mode 100644 skills/codex-artifacts/scripts/publisher-core.mjs create mode 100644 test/publisher-core.test.mjs diff --git a/package.json b/package.json index 57c1f5d..d36bcc8 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "type": "module", "scripts": { "build": "npx lakebed@0.0.29 build . --target anonymous", + "check": "npm run test && npm run build", "dev": "npx lakebed@0.0.29 dev", - "deploy": "npx lakebed@0.0.29 deploy" + "deploy": "npx lakebed@0.0.29 deploy", + "test": "node --test test/*.test.mjs" } } diff --git a/skills/codex-artifacts/scripts/publish.mjs b/skills/codex-artifacts/scripts/publish.mjs index 6697930..4ecaa27 100644 --- a/skills/codex-artifacts/scripts/publish.mjs +++ b/skills/codex-artifacts/scripts/publish.mjs @@ -1,11 +1,17 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; -import { readFile } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import { basename, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { + parseArguments, + parseEnv, + resolvePublishingProfile +} from "./publisher-core.mjs"; -const OPTION_NAMES = new Set(["--title", "--slug", "--share"]); +const MAX_ARTIFACT_BYTES = 512 * 1024; +const PUBLISH_TIMEOUT_MS = 30_000; function usage() { console.error(`Usage: @@ -24,46 +30,6 @@ Environment: ARTIFACTS_AUTO_OPEN=0 Disable opening the published URL`); } -function option(args, name) { - const index = args.indexOf(name); - return index >= 0 ? args[index + 1] : undefined; -} - -function optionValues(args, name) { - const values = []; - for (let index = 0; index < args.length; index += 1) { - if (args[index] === name && args[index + 1]) { - values.push(args[index + 1]); - index += 1; - } - } - return values; -} - -function positional(args) { - for (let index = 0; index < args.length; index += 1) { - const value = args[index]; - if (OPTION_NAMES.has(value)) { - index += 1; - continue; - } - if (!value.startsWith("--")) { - return value; - } - } - return undefined; -} - -function parseEnv(source) { - const values = {}; - for (const line of source.split(/\r?\n/)) { - const match = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)\s*$/); - if (!match) continue; - values[match[1]] = match[2].replace(/^(['"])(.*)\1$/, "$2"); - } - return values; -} - async function readConfiguration() { const scriptDirectory = dirname(fileURLToPath(import.meta.url)); const envPath = process.env.CODEX_ARTIFACTS_ENV @@ -71,14 +37,17 @@ async function readConfiguration() { : resolve(scriptDirectory, "../../../.env.lakebed.server"); try { - return parseEnv(await readFile(envPath, "utf8")); - } catch { - return {}; + return { path: envPath, values: parseEnv(await readFile(envPath, "utf8")) }; + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return { path: envPath, values: {} }; + } + throw error; } } -function openInBrowser(url) { - if (args.includes("--no-open") || process.env.ARTIFACTS_AUTO_OPEN === "0") { +function openInBrowser(url, noOpen) { + if (noOpen || process.env.ARTIFACTS_AUTO_OPEN === "0") { return; } @@ -102,59 +71,83 @@ function openInBrowser(url) { } } -const args = process.argv.slice(2); -const fileArg = positional(args); -const configuration = await readConfiguration(); -const baseUrl = (process.env.ARTIFACTS_URL ?? configuration.ARTIFACTS_URL ?? "").replace(/\/$/, ""); -const token = - process.env.ARTIFACTS_PUBLISH_TOKEN ?? - process.env.PUBLISH_TOKEN ?? - configuration.ARTIFACTS_PUBLISH_TOKEN ?? - configuration.PUBLISH_TOKEN; - -if (!fileArg || !baseUrl || !token) { - usage(); - process.exit(1); -} +async function main() { + const args = parseArguments(process.argv.slice(2)); + if (args.help) { + usage(); + return; + } + if (!args.file) { + throw new Error("An HTML file is required. Use --help for usage."); + } -const filePath = resolve(fileArg); -const html = await readFile(filePath, "utf8"); -const fileName = basename(filePath).replace(/\.html?$/i, ""); -const title = option(args, "--title") ?? fileName; -const slug = option(args, "--slug"); -const shareOptions = optionValues(args, "--share"); -const sharedWith = shareOptions - .flatMap((value) => value.split(",")) - .map((value) => value.trim()) - .filter(Boolean); - -const payload = { title, slug, html }; -if (shareOptions.length > 0) { - payload.sharedWith = sharedWith; -} -if (args.includes("--public")) { - payload.isPublic = true; -} + const configuration = await readConfiguration(); + const { baseUrl, token } = resolvePublishingProfile(process.env, configuration.values); + const filePath = resolve(args.file); + const fileInfo = await stat(filePath); + if (!fileInfo.isFile()) { + throw new Error(`${filePath} is not a regular file.`); + } + if (fileInfo.size > MAX_ARTIFACT_BYTES) { + throw new Error("Artifact exceeds the 512 KiB limit."); + } -const response = await fetch(`${baseUrl}/api/artifacts`, { - method: "POST", - headers: { - authorization: `Bearer ${token}`, - "content-type": "application/json" - }, - body: JSON.stringify(payload) -}); + const html = await readFile(filePath, "utf8"); + if (Buffer.byteLength(html, "utf8") > MAX_ARTIFACT_BYTES) { + throw new Error("Artifact exceeds the 512 KiB limit."); + } + const fileName = basename(filePath).replace(/\.html?$/i, ""); + const payload = { + title: args.title ?? fileName, + slug: args.slug, + html + }; + if (args.sharedWith.length > 0) { + payload.sharedWith = args.sharedWith; + } + if (args.isPublic) { + payload.isPublic = true; + } -const body = await response.json().catch(() => ({})); -if (!response.ok) { - console.error(body.error ?? `Publish failed with HTTP ${response.status}`); - process.exit(1); -} -if (args.includes("--public") && body.isPublic !== true) { - console.error("Publish succeeded, but the server did not confirm public access."); - process.exit(1); + const response = await fetch(`${baseUrl}/api/artifacts`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json" + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(PUBLISH_TIMEOUT_MS) + }); + + const responseText = await response.text(); + let body = {}; + try { + body = responseText ? JSON.parse(responseText) : {}; + } catch { + if (!response.ok) { + throw new Error(`Publish failed with HTTP ${response.status}.`); + } + throw new Error("Publish succeeded but returned an invalid JSON response."); + } + if (!response.ok) { + throw new Error( + typeof body.error === "string" ? body.error : `Publish failed with HTTP ${response.status}.` + ); + } + if (typeof body.slug !== "string" || !body.slug) { + throw new Error("Publish succeeded but returned no artifact slug."); + } + if (args.isPublic && body.isPublic !== true) { + throw new Error("Publish succeeded, but the server did not confirm public access."); + } + + const artifactUrl = `${baseUrl}/a/${body.slug}`; + console.log(artifactUrl); + openInBrowser(artifactUrl, args.noOpen); } -const artifactUrl = `${baseUrl}/a/${body.slug}`; -console.log(artifactUrl); -openInBrowser(artifactUrl); +main().catch((error) => { + const message = error instanceof Error ? error.message : "Unable to publish artifact."; + console.error(message); + process.exitCode = 1; +}); diff --git a/skills/codex-artifacts/scripts/publisher-core.mjs b/skills/codex-artifacts/scripts/publisher-core.mjs new file mode 100644 index 0000000..f4d28a3 --- /dev/null +++ b/skills/codex-artifacts/scripts/publisher-core.mjs @@ -0,0 +1,138 @@ +const OPTIONS_WITH_VALUES = new Set(["--title", "--slug", "--share"]); +const FLAG_OPTIONS = new Set(["--public", "--no-open", "--help", "-h"]); + +export function parseArguments(args) { + const result = { + file: undefined, + title: undefined, + slug: undefined, + sharedWith: [], + isPublic: false, + noOpen: false, + help: false + }; + let positionalOnly = false; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + + if (!positionalOnly && argument === "--") { + positionalOnly = true; + continue; + } + + if (!positionalOnly && OPTIONS_WITH_VALUES.has(argument)) { + const value = args[index + 1]; + if (value === undefined || value === "--" || OPTIONS_WITH_VALUES.has(value) || FLAG_OPTIONS.has(value)) { + throw new Error(`${argument} requires a value.`); + } + index += 1; + + if (argument === "--share") { + result.sharedWith.push( + ...value + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + ); + continue; + } + + const property = argument === "--title" ? "title" : "slug"; + if (result[property] !== undefined) { + throw new Error(`${argument} may only be supplied once.`); + } + result[property] = value; + continue; + } + + if (!positionalOnly && FLAG_OPTIONS.has(argument)) { + if (argument === "--public") result.isPublic = true; + if (argument === "--no-open") result.noOpen = true; + if (argument === "--help" || argument === "-h") result.help = true; + continue; + } + + if (!positionalOnly && argument.startsWith("-")) { + throw new Error(`Unknown option: ${argument}`); + } + + if (result.file !== undefined) { + throw new Error("Only one HTML file may be published at a time."); + } + result.file = argument; + } + + return result; +} + +export function parseEnv(source) { + const values = {}; + for (const line of source.split(/\r?\n/)) { + const match = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)\s*$/); + if (!match) continue; + values[match[1]] = match[2].replace(/^(['"])(.*)\1$/, "$2"); + } + return values; +} + +function nonempty(value) { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +export function validateBaseUrl(value) { + const raw = nonempty(value); + if (!raw) { + throw new Error("ARTIFACTS_URL is required."); + } + + let parsed; + try { + parsed = new URL(raw); + } catch { + throw new Error("ARTIFACTS_URL must be a valid absolute URL."); + } + + const isLoopback = parsed.hostname === "localhost" || + parsed.hostname === "127.0.0.1" || + parsed.hostname === "::1"; + if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopback)) { + throw new Error("ARTIFACTS_URL must use HTTPS, except for a loopback development URL."); + } + if (parsed.username || parsed.password) { + throw new Error("ARTIFACTS_URL must not contain credentials."); + } + if ((parsed.pathname && parsed.pathname !== "/") || parsed.search || parsed.hash) { + throw new Error("ARTIFACTS_URL must be an origin without a path, query, or fragment."); + } + + return parsed.origin; +} + +export function resolvePublishingProfile(environment, configuration) { + const environmentUrl = nonempty(environment.ARTIFACTS_URL); + const environmentToken = nonempty(environment.ARTIFACTS_PUBLISH_TOKEN); + const configuredUrl = nonempty(configuration.ARTIFACTS_URL); + const configuredToken = + nonempty(configuration.ARTIFACTS_PUBLISH_TOKEN) ?? + nonempty(configuration.PUBLISH_TOKEN); + + if (environmentUrl && !environmentToken) { + throw new Error( + "ARTIFACTS_URL was overridden in the process environment without ARTIFACTS_PUBLISH_TOKEN. " + + "Set both together so a configured token cannot be redirected to another service." + ); + } + + const baseUrl = validateBaseUrl(environmentUrl ?? configuredUrl); + const token = environmentToken ?? configuredToken; + if (!token) { + throw new Error("ARTIFACTS_PUBLISH_TOKEN is required."); + } + + return { + baseUrl, + token, + source: environmentUrl ? "environment" : environmentToken ? "environment-token" : "configuration" + }; +} diff --git a/test/publisher-core.test.mjs b/test/publisher-core.test.mjs new file mode 100644 index 0000000..5359348 --- /dev/null +++ b/test/publisher-core.test.mjs @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + parseArguments, + parseEnv, + resolvePublishingProfile, + validateBaseUrl +} from "../skills/codex-artifacts/scripts/publisher-core.mjs"; + +test("parses publishing options and repeated recipients", () => { + assert.deepEqual( + parseArguments([ + "report.html", + "--title", + "Architecture", + "--slug", + "architecture", + "--share", + "one@example.com,two@example.com", + "--share", + "three@example.com", + "--public", + "--no-open" + ]), + { + file: "report.html", + title: "Architecture", + slug: "architecture", + sharedWith: ["one@example.com", "two@example.com", "three@example.com"], + isPublic: true, + noOpen: true, + help: false + } + ); +}); + +test("supports an option-like filename after the option terminator", () => { + assert.equal(parseArguments(["--", "--report.html"]).file, "--report.html"); +}); + +test("rejects unknown options, duplicate scalar options, and missing values", () => { + assert.throws(() => parseArguments(["--unknown"]), /Unknown option/); + assert.throws( + () => parseArguments(["report.html", "--slug", "one", "--slug", "two"]), + /only be supplied once/ + ); + assert.throws(() => parseArguments(["report.html", "--title", "--public"]), /requires a value/); +}); + +test("parses simple quoted environment files as data", () => { + assert.deepEqual( + parseEnv("ARTIFACTS_URL='https://artifacts.example.com'\nPUBLISH_TOKEN=\"secret\"\n"), + { + ARTIFACTS_URL: "https://artifacts.example.com", + PUBLISH_TOKEN: "secret" + } + ); +}); + +test("resolves URL and token from one configuration file", () => { + assert.deepEqual( + resolvePublishingProfile({}, { + ARTIFACTS_URL: "https://artifacts.example.com/", + PUBLISH_TOKEN: "configured-secret" + }), + { + baseUrl: "https://artifacts.example.com", + token: "configured-secret", + source: "configuration" + } + ); +}); + +test("does not redirect a configured token with an environment URL", () => { + assert.throws( + () => resolvePublishingProfile( + { ARTIFACTS_URL: "https://attacker.example.com" }, + { + ARTIFACTS_URL: "https://artifacts.example.com", + PUBLISH_TOKEN: "configured-secret" + } + ), + /Set both together/ + ); +}); + +test("accepts a paired environment profile", () => { + assert.deepEqual( + resolvePublishingProfile( + { + ARTIFACTS_URL: "https://other.example.com", + ARTIFACTS_PUBLISH_TOKEN: "environment-secret" + }, + { + ARTIFACTS_URL: "https://artifacts.example.com", + PUBLISH_TOKEN: "configured-secret" + } + ), + { + baseUrl: "https://other.example.com", + token: "environment-secret", + source: "environment" + } + ); +}); + +test("preserves a token-only environment override for the configured service", () => { + assert.deepEqual( + resolvePublishingProfile( + { ARTIFACTS_PUBLISH_TOKEN: "environment-secret" }, + { ARTIFACTS_URL: "https://artifacts.example.com", PUBLISH_TOKEN: "configured-secret" } + ), + { + baseUrl: "https://artifacts.example.com", + token: "environment-secret", + source: "environment-token" + } + ); +}); + +test("requires HTTPS except for loopback development", () => { + assert.equal(validateBaseUrl("http://localhost:3000/"), "http://localhost:3000"); + assert.throws(() => validateBaseUrl("http://artifacts.example.com"), /must use HTTPS/); + assert.throws(() => validateBaseUrl("https://artifacts.example.com/path"), /without a path/); +}); From 53823d46e6dc38e4503369c8a0f4fb97a6e537f3 Mon Sep 17 00:00:00 2001 From: none23 Date: Sun, 26 Jul 2026 17:40:57 +0400 Subject: [PATCH 2/6] Bind artifact access to durable identities --- client/index.tsx | 85 +++++++++++++++- server/index.ts | 246 +++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 296 insertions(+), 35 deletions(-) diff --git a/client/index.tsx b/client/index.tsx index b967d8f..85eb500 100644 --- a/client/index.tsx +++ b/client/index.tsx @@ -10,7 +10,7 @@ import { useLocation, useParams } from "lakebed/client"; -import { useMemo, useState } from "preact/hooks"; +import { useEffect, useMemo, useState } from "preact/hooks"; import type app from "../server"; import { MAX_ARTIFACT_BYTES, @@ -284,6 +284,36 @@ function NonOwnerHome() { ); } +function useOwnerBootstrap() { + const viewer = client.useQuery("viewer"); + const claimOwnerAccess = client.useMutation("claimOwnerAccess"); + const [state, setState] = useState<"idle" | "claiming" | "claimed" | "error">("idle"); + const [error, setError] = useState(""); + + useEffect(() => { + if (!viewer || viewer.isOwner || !viewer.canClaimOwner || state !== "idle") { + return; + } + + setState("claiming"); + void claimOwnerAccess() + .then((result) => { + if (!result.claimed) { + setError("This Google identity could not accept the configured owner invitation."); + setState("error"); + return; + } + setState("claimed"); + }) + .catch((caught) => { + setError(messageFromError(caught)); + setState("error"); + }); + }, [viewer?.isOwner, viewer?.canClaimOwner, state]); + + return { viewer, state, error }; +} + type ViewedArtifact = Exclude>, null | undefined>; function AccessControl({ artifact }: { artifact: ViewedArtifact }) { @@ -439,12 +469,39 @@ function ArtifactFrame() { const auth = useAuth(); const { slug = "" } = useParams<{ slug: string }>(); const artifact = client.useQuery("artifactBySlug", slug); + const acceptArtifactAccess = client.useMutation("acceptArtifactAccess"); + const ownerBootstrap = useOwnerBootstrap(); + const [accessState, setAccessState] = useState<"idle" | "accepting" | "accepted" | "denied">("idle"); const [copied, setCopied] = useState(false); const downloadUrl = useMemo(() => { if (!artifact) return ""; return URL.createObjectURL(new Blob([artifact.html], { type: "text/html;charset=utf-8" })); }, [artifact?.html]); + useEffect(() => { + if ( + artifact !== null || + auth.isLoading || + auth.isGuest || + accessState !== "idle" || + ownerBootstrap.state === "claiming" + ) { + return; + } + + setAccessState("accepting"); + void acceptArtifactAccess(slug) + .then((result) => setAccessState(result.accepted ? "accepted" : "denied")) + .catch(() => setAccessState("denied")); + }, [ + artifact, + auth.isLoading, + auth.isGuest, + slug, + accessState, + ownerBootstrap.state + ]); + if (artifact === undefined) { return
Opening artifact…
; } @@ -452,6 +509,14 @@ function ArtifactFrame() { if (auth.isGuest) { return ; } + if ( + accessState === "accepting" || + accessState === "accepted" || + ownerBootstrap.state === "claiming" || + ownerBootstrap.state === "claimed" + ) { + return
Verifying shared access…
; + } return (

Not available

@@ -507,11 +572,25 @@ function RootPage() { } function SignedInRoot() { - const viewer = client.useQuery("viewer"); + const { viewer, state, error } = useOwnerBootstrap(); if (!viewer) { return
Loading workspace…
; } - return viewer.isOwner ? : ; + if (viewer.isOwner) { + return ; + } + if (state === "claiming" || state === "claimed") { + return
Activating owner access…
; + } + if (state === "error") { + return ( +
+

Owner access could not be activated.

+

{error}

+
+ ); + } + return ; } function ArtifactPage() { diff --git a/server/index.ts b/server/index.ts index 04e7a4f..2396e48 100644 --- a/server/index.ts +++ b/server/index.ts @@ -9,6 +9,7 @@ import { string, table, text, + type QueryServerContext, type ServerContext, type WriteDatabaseForSchema } from "lakebed/server"; @@ -47,11 +48,36 @@ const schema = { artifactId: id("artifacts"), part: string(), content: string() - }).index("by_artifact_part", ["artifactId", "part"]) + }).index("by_artifact_part", ["artifactId", "part"]), + ownerBindings: table({ + userId: string(), + invitedEmail: string() + }) + .index("by_user_id", ["userId"]) + .index("by_invited_email", ["invitedEmail"]), + artifactGrants: table({ + artifactId: id("artifacts"), + userId: string(), + ruleType: string(), + ruleValue: string() + }) + .index("by_artifact_user", ["artifactId", "userId"]) + .index("by_artifact", ["artifactId"]) }; type AppContext = ServerContext>; type EnvironmentContext = { env: ServerContext["env"] }; +type AuthEnvironmentContext = Pick; +type OwnerReadContext = AuthEnvironmentContext & { + db: { + ownerBindings: Pick; + }; +}; +type GrantReadContext = { + db: { + artifactGrants: Pick; + }; +}; type PublishInput = { artifactId?: string; @@ -82,7 +108,9 @@ function isConfiguredOwner(ctx: EnvironmentContext, value: string): boolean { return ownerEmails(ctx).includes(normalizeEmail(value)); } -function authenticatedEmail(ctx: { auth: ServerContext["auth"] }): string | null { +function authenticatedIdentity( + ctx: { auth: ServerContext["auth"] } +): { userId: string; email: string } | null { if ( !ctx.auth.isAuthenticated || ctx.auth.provider !== "google" || @@ -91,16 +119,102 @@ function authenticatedEmail(ctx: { auth: ServerContext["auth"] }): string | null ) { return null; } - return normalizeEmail(ctx.auth.email); + return { + userId: ctx.auth.userId, + email: normalizeEmail(ctx.auth.email) + }; +} + +async function ownerBinding(ctx: OwnerReadContext, userId: string) { + return ctx.db.ownerBindings + .withIndex("by_user_id", (q) => q.eq("userId", userId)) + .first(); +} + +async function hasOwnerAccess(ctx: OwnerReadContext): Promise { + const identity = authenticatedIdentity(ctx); + if (!identity) { + return false; + } + const binding = await ownerBinding(ctx, identity.userId); + return Boolean(binding && ownerEmails(ctx).includes(binding.invitedEmail)); } -function requireOwner(ctx: { auth: ServerContext["auth"]; env: ServerContext["env"] }): void { - const email = authenticatedEmail(ctx); - if (!email || !isConfiguredOwner(ctx, email)) { +async function requireOwner(ctx: OwnerReadContext): Promise { + if (!(await hasOwnerAccess(ctx))) { throw new Error("Only the artifact owner can perform this action."); } } +async function claimConfiguredOwner(ctx: AppContext): Promise { + const identity = authenticatedIdentity(ctx); + if (!identity || !isConfiguredOwner(ctx, identity.email)) { + return false; + } + + const existingForUser = await ownerBinding(ctx, identity.userId); + if (existingForUser?.invitedEmail === identity.email) { + return true; + } + + const existingForInvitation = await ctx.db.ownerBindings + .withIndex("by_invited_email", (q) => q.eq("invitedEmail", identity.email)) + .first(); + if (existingForInvitation && existingForInvitation.userId !== identity.userId) { + throw new Error("This owner invitation has already been accepted by another identity."); + } + + if (existingForUser) { + await ctx.db.ownerBindings.update(existingForUser.id, { + invitedEmail: identity.email + }); + } else if (!existingForInvitation) { + await ctx.db.ownerBindings.insert({ + userId: identity.userId, + invitedEmail: identity.email + }); + } + return true; +} + +async function validArtifactGrant( + ctx: GrantReadContext, + artifactId: string, + userId: string, + sharedWith: string[], + sharedDomains: string[] +): Promise { + const grants = await ctx.db.artifactGrants + .withIndex("by_artifact_user", (q) => + q.eq("artifactId", artifactId).eq("userId", userId) + ) + .collect(); + return grants.some((grant) => + grant.ruleType === "email" + ? sharedWith.includes(grant.ruleValue) + : grant.ruleType === "domain" && sharedDomains.includes(grant.ruleValue) + ); +} + +async function pruneArtifactGrants( + ctx: AppContext, + artifactId: string, + sharedWith: string[], + sharedDomains: string[] +) { + const grants = await ctx.db.artifactGrants + .withIndex("by_artifact", (q) => q.eq("artifactId", artifactId)) + .collect(); + for (const grant of grants) { + const isValid = + (grant.ruleType === "email" && sharedWith.includes(grant.ruleValue)) || + (grant.ruleType === "domain" && sharedDomains.includes(grant.ruleValue)); + if (!isValid) { + await ctx.db.artifactGrants.delete(grant.id); + } + } +} + function validateChunks(chunks: string[]): number { if (!Array.isArray(chunks) || chunks.length === 0) { throw new Error("Artifact HTML cannot be empty."); @@ -191,6 +305,12 @@ async function publishAsOwner( sizeBytes: String(validated.sizeBytes), chunkCount: String(input.chunks.length) }); + await pruneArtifactGrants( + ctx, + artifact.id, + validated.sharedWith, + parseSharedEmails(artifact.sharedDomains) + ); return { id: artifact.id, slug: validated.slug }; } @@ -222,29 +342,23 @@ export default capsule({ schema, queries: { - viewer: query((ctx) => { - const email = authenticatedEmail(ctx); - return { isOwner: Boolean(email && isConfiguredOwner(ctx, email)) }; + viewer: query(async (ctx) => { + const identity = authenticatedIdentity(ctx); + return { + isOwner: await hasOwnerAccess(ctx), + canClaimOwner: Boolean(identity && isConfiguredOwner(ctx, identity.email)) + }; }), ownedArtifacts: query(async (ctx) => { - const email = authenticatedEmail(ctx); - if (!email || !isConfiguredOwner(ctx, email)) { + if (!(await hasOwnerAccess(ctx))) { return []; } - const artifactGroups = []; - for (const ownerEmail of ownerEmails(ctx)) { - artifactGroups.push( - await ctx.db.artifacts - .withIndex("by_owner_email", (q) => q.eq("ownerEmail", ownerEmail)) - .order("desc") - .collect() - ); - } - const artifacts = artifactGroups - .flat() - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + const artifacts = await ctx.db.artifacts + .withIndex("by_creation") + .order("desc") + .collect(); return artifacts.map((artifact) => ({ ...artifact, @@ -266,16 +380,22 @@ export default capsule({ const sharedWith = parseSharedEmails(artifact.sharedWith); const sharedDomains = parseSharedEmails(artifact.sharedDomains); const isPublic = artifact.isPublic === true; - const email = authenticatedEmail(ctx); + const identity = authenticatedIdentity(ctx); const configuredOwners = ownerEmails(ctx); - const canManage = Boolean(email && configuredOwners.includes(email)); + const canManage = await hasOwnerAccess(ctx); + const hasGrant = identity + ? await validArtifactGrant( + ctx, + artifact.id, + identity.userId, + sharedWith, + sharedDomains + ) + : false; const canView = isPublic || canManage || - Boolean(email && ( - sharedWith.includes(email) || - sharedDomains.includes(emailDomain(email)) - )); + hasGrant; if (!canView) { return null; } @@ -302,8 +422,63 @@ export default capsule({ }, mutations: { + claimOwnerAccess: mutation(async (ctx) => ({ + claimed: await claimConfiguredOwner(ctx) + })), + + acceptArtifactAccess: mutation(async (ctx, slugInput: string) => { + const identity = authenticatedIdentity(ctx); + if (!identity) { + return { accepted: false }; + } + if (await claimConfiguredOwner(ctx)) { + return { accepted: true }; + } + + const slug = cleanSlug(slugInput); + const artifact = await ctx.db.artifacts + .withIndex("by_slug", (q) => q.eq("slug", slug)) + .first(); + if (!artifact) { + return { accepted: false }; + } + if (artifact.isPublic === true) { + return { accepted: true }; + } + + const sharedWith = parseSharedEmails(artifact.sharedWith); + const sharedDomains = parseSharedEmails(artifact.sharedDomains); + const domain = emailDomain(identity.email); + const ruleType = sharedWith.includes(identity.email) + ? "email" + : sharedDomains.includes(domain) + ? "domain" + : null; + const ruleValue = ruleType === "email" ? identity.email : domain; + if (!ruleType) { + return { accepted: false }; + } + + const grants = await ctx.db.artifactGrants + .withIndex("by_artifact_user", (q) => + q.eq("artifactId", artifact.id).eq("userId", identity.userId) + ) + .collect(); + if (!grants.some((grant) => + grant.ruleType === ruleType && grant.ruleValue === ruleValue + )) { + await ctx.db.artifactGrants.insert({ + artifactId: artifact.id, + userId: identity.userId, + ruleType, + ruleValue + }); + } + return { accepted: true }; + }), + publishArtifact: mutation(async (ctx, input: PublishInput) => { - requireOwner(ctx); + await requireOwner(ctx); return publishAsOwner(ctx, input, ctx.auth.userId); }), @@ -312,7 +487,7 @@ export default capsule({ artifactId: string, access: { emails: string[]; domains: string[]; isPublic: boolean } ) => { - requireOwner(ctx); + await requireOwner(ctx); const artifact = await ctx.db.artifacts.get(artifactId); if (!artifact) { throw new Error("Artifact not found."); @@ -331,11 +506,12 @@ export default capsule({ sharedDomains: JSON.stringify(sharedDomains), isPublic: access.isPublic === true }); + await pruneArtifactGrants(ctx, artifact.id, sharedWith, sharedDomains); return { emails: sharedWith, domains: sharedDomains, isPublic: access.isPublic === true }; }), deleteArtifact: mutation(async (ctx, artifactId: string) => { - requireOwner(ctx); + await requireOwner(ctx); const artifact = await ctx.db.artifacts.get(artifactId); if (!artifact) { throw new Error("Artifact not found."); @@ -347,6 +523,12 @@ export default capsule({ for (const chunk of chunks) { await ctx.db.artifactChunks.delete(chunk.id); } + const grants = await ctx.db.artifactGrants + .withIndex("by_artifact", (q) => q.eq("artifactId", artifact.id)) + .collect(); + for (const grant of grants) { + await ctx.db.artifactGrants.delete(grant.id); + } await ctx.db.artifacts.delete(artifact.id); }) }, From 035df6e877839d4ddb65e8d348d72fa673c2b1f8 Mon Sep 17 00:00:00 2001 From: none23 Date: Sun, 26 Jul 2026 17:45:17 +0400 Subject: [PATCH 3/6] Add bring-your-own Lakebed setup --- AGENTS.md | 2 +- README.md | 175 ++++++++++++++++++++++++---------- package.json | 1 + scripts/setup.mjs | 223 ++++++++++++++++++++++++++++++++++++++++++++ test/setup.test.mjs | 54 +++++++++++ 5 files changed, 403 insertions(+), 52 deletions(-) create mode 100644 scripts/setup.mjs create mode 100644 test/setup.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 28ab802..f6f1081 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ This repository is a Lakebed v0 capsule. Keep application code within `server/`, `client/`, and `shared/`; do not add runtime npm dependencies or Node built-ins to capsule code. - Run `npm run build` after changes. -- Keep authorization server-side. Owner identities are the verified Google emails in `OWNER_EMAILS` within `shared/config.ts`. +- Keep authorization server-side. Emails locate pending invitations; durable owner and recipient authorization uses immutable Lakebed user IDs. - New artifacts must remain private until explicit recipient emails are saved. - Only owners may change access. Preserve exact-email, domain, and public settings when replacing or republishing HTML. - Never add `allow-same-origin` to the artifact iframe sandbox. Artifact HTML is untrusted relative to the authenticated shell. diff --git a/README.md b/README.md index 876b7bb..4c0862c 100644 --- a/README.md +++ b/README.md @@ -1,116 +1,189 @@ # Codex Artifacts -Codex Artifacts turns self-contained HTML files into shareable web pages with access control. It is designed for reports, dashboards, plans, walkthroughs, and other visual documents created by coding agents. +Codex Artifacts turns self-contained HTML files into private, shareable web pages. It is designed for reports, dashboards, plans, walkthroughs, and other visual documents created by coding agents. -Artifacts are private by default. Owners can share an artifact with individual Google accounts, allow an entire email domain, or make it public. +Each operator deploys the service into their own Lakebed account. There is no shared hosted publishing service and no shared credential: your deployment owns its data, owner invitations, URL, and publishing token. [View the public README demo](https://codex-artifacts.lakebed.app/a/readme-demo) ## What you get -- Google sign-in and verified-email access checks +- Google sign-in with durable Lakebed identity bindings - Private-by-default artifact publishing -- Per-artifact sharing by email, domain, or public link +- Per-artifact invitations by exact email or email domain +- Optional public links - A browser UI for uploading, replacing, downloading, and deleting artifacts - A shared Codex and Claude Code skill for agent-driven publishing - A command-line publisher that can update an existing artifact URL -- Sandboxed HTML previews +- Sandboxed HTML previews without `allow-same-origin` -## Use it +## Quick start -Once the service and skill are installed, ask your agent for an artifact: +You need Node.js 20 or later, a Google account, and a free [Lakebed](https://lakebed.dev) account. + +```sh +git clone https://github.com/none23/codex-artifacts.git +cd codex-artifacts +npm run setup -- --owner you@example.com +``` + +Setup: + +1. Creates a random 256-bit publishing token. +2. Writes the ignored `.env.lakebed.server` with mode `0600`. +3. Opens Lakebed developer login if needed. +4. Creates an owned deployment or updates the deployment already bound in `lakebed.json`. +5. Saves the deployment URL and verifies `/api/status`. + +Re-run `npm run setup` after pulling an update. Existing owners, secrets, and a configured custom URL are preserved unless you explicitly replace the owner list with `--owner`. + +### Install the agent skill + +Link the same skill directory for Codex, Claude Code, or both: + +```sh +REPO_DIR="$(pwd)" + +mkdir -p "${CODEX_HOME:-$HOME/.codex}/skills" +ln -sfn "$REPO_DIR/skills/codex-artifacts" \ + "${CODEX_HOME:-$HOME/.codex}/skills/codex-artifacts" + +mkdir -p "$HOME/.claude/skills" +ln -sfn "$REPO_DIR/skills/codex-artifacts" \ + "$HOME/.claude/skills/codex-artifacts" +``` + +If the skill is copied away from this repository, set `CODEX_ARTIFACTS_ENV` to the absolute path of `.env.lakebed.server`. + +### Publish + +Ask your agent: ```text Use codex-artifacts to create and publish a visual architecture report for this repository. ``` -Codex can select the skill automatically when the request calls for a shareable visual page. In Claude Code, invoke `/codex-artifacts` explicitly so it does not conflict with Claude's built-in artifact feature. - -You can also publish an existing HTML file from the repository: +Or publish an existing file: ```sh -node scripts/publish.mjs ./report.html \ - --title "Architecture report" +node scripts/publish.mjs ./report.html --title "Architecture report" ``` Useful options: ```sh ---slug architecture-report # Reuse the same URL on future updates ---share person@example.com # Share with one or more exact emails +--slug architecture-report # Reuse the URL on future updates +--share person@example.com # Replace the exact-email invitation list --public # Allow anyone with the link to view --no-open # Do not open the result in a browser +-- --option-like-name.html # Publish a filename beginning with "-" ``` -Open a published artifact and use **Access** in the top bar to manage people, domains, and public visibility. Email suggestions are remembered locally in that browser. +Omitting `--share` or `--public` while updating preserves existing access. The publisher refuses to combine a process-level URL override with a token loaded from the configuration file; override `ARTIFACTS_URL` and `ARTIFACTS_PUBLISH_TOKEN` together. -## Install +## Identity and access -You need Node.js, a Google account, and a free [Lakebed](https://lakebed.dev) deployment. +`OWNER_EMAILS` and artifact recipient emails are invitations, not permanent authorization keys. -### 1. Configure and deploy the service +- The first matching verified Google sign-in accepts an invitation and binds it to the account's immutable Lakebed user ID. +- Later requests authorize the bound user ID rather than trusting current profile email. +- Removing an owner invitation from `OWNER_EMAILS` and redeploying revokes that binding. +- Removing an artifact email or domain rule immediately invalidates and removes grants created from that rule. +- Every configured owner is a deployment administrator and can manage every artifact. + +Domain invitations are broad. Do not add public mail domains such as `gmail.com`; every matching signed-in account could accept access. Prefer exact-email invitations for sensitive artifacts. + +## Manual setup + +The setup command is recommended, but the equivalent manual flow is: ```sh -git clone codex-artifacts -cd codex-artifacts cp .env.lakebed.server.example .env.lakebed.server +chmod 600 .env.lakebed.server ``` -Edit the ignored `.env.lakebed.server`: +Set: ```dotenv -OWNER_EMAILS=you@example.com,another-account@example.com +OWNER_EMAILS=you@example.com PUBLISH_TOKEN=replace-with-a-long-random-secret ARTIFACTS_URL=https://your-artifacts.lakebed.app ``` -Generate a publish token with `openssl rand -hex 32`. Then deploy and claim the app: +Generate the token with `openssl rand -hex 32`. Authenticate before the first deployment so Lakebed creates an owned app: ```sh -npm run deploy npx lakebed@0.0.29 auth login -npx lakebed@0.0.29 claim +npm run deploy ``` -Set `ARTIFACTS_URL` to the URL Lakebed gives you and deploy once more so local publishing uses the final address: +Set `ARTIFACTS_URL` to the deployed or custom URL. `lakebed.json` is intentionally ignored in this upstream repository so a clone never targets the maintainer's deployment; Lakebed creates your local binding automatically. + +## Configuration + +| Variable | Purpose | +| --- | --- | +| `OWNER_EMAILS` | Comma-separated pending/current owner invitations | +| `PUBLISH_TOKEN` | Server-side automation secret | +| `ARTIFACTS_URL` | Publisher destination in the local configuration file | +| `ARTIFACTS_PUBLISH_TOKEN` | Process-level publisher token override | +| `ARTIFACTS_AUTO_OPEN=0` | Disables opening newly published artifacts | +| `CODEX_ARTIFACTS_ENV` | Optional absolute publisher environment-file path | +| `LAKEBED_TOKEN` | Optional Lakebed deployment credential for automation | + +Never commit `.env.lakebed.server`, `.lakebed/`, `lakebed.json`, or publishing/deployment tokens. + +## Operations + +### Update safely ```sh -npm run deploy +git pull --ff-only +npm run check +npm run setup ``` -### 2. Install the agent skill +`npm run setup` updates the bound deployment only after tests/build are run separately. For a controlled rollback, check out the last known-good revision, run `npm run check`, then `npm run deploy`. -Link the same skill directory for Codex, Claude Code, or both: +### Back up data + +Read the deploy ID from the ignored `lakebed.json`, then export: ```sh -REPO_DIR="$(pwd)" +DEPLOY_ID="$(node -p "JSON.parse(require('fs').readFileSync('lakebed.json')).deployId")" +npx lakebed@0.0.29 db export "$DEPLOY_ID" --out codex-artifacts-backup.json +``` -mkdir -p "${CODEX_HOME:-$HOME/.codex}/skills" -ln -sfn "$REPO_DIR/skills/codex-artifacts" \ - "${CODEX_HOME:-$HOME/.codex}/skills/codex-artifacts" +Lakebed export is not a point-in-time snapshot during concurrent writes. Keep backups private: they contain artifact HTML, owner invitations, and recipient access data. -mkdir -p "$HOME/.claude/skills" -ln -sfn "$REPO_DIR/skills/codex-artifacts" \ - "$HOME/.claude/skills/codex-artifacts" -``` +### Rotate the publisher token -If your skill directory differs, set `CODEX_ARTIFACTS_SKILL_DIR` to the installed skill path. If the skill is copied away from the repository, set `CODEX_ARTIFACTS_ENV` to the absolute path of your `.env.lakebed.server`. +Replace `PUBLISH_TOKEN` in `.env.lakebed.server` with a new 64-character hex value, keep the file at mode `0600`, and run `npm run deploy`. The publisher reads the same local file, so the old token stops working after deployment. -## Configuration +### Change owners -| Variable | Purpose | -| --- | --- | -| `OWNER_EMAILS` | Comma-separated Google accounts that can manage every artifact | -| `PUBLISH_TOKEN` | Server secret accepted by the automation endpoint | -| `ARTIFACTS_URL` | Public base URL used by the publishing script | -| `ARTIFACTS_PUBLISH_TOKEN` | Optional local override for `PUBLISH_TOKEN` | -| `ARTIFACTS_AUTO_OPEN=0` | Disables opening newly published artifacts | -| `CODEX_ARTIFACTS_ENV` | Optional path to the publisher environment file | +Update `OWNER_EMAILS` and run `npm run deploy`. Removing an email revokes its bound owner access. Adding an email creates a pending invitation that binds on that person's next sign-in. -Never commit `.env.lakebed.server`, `lakebed.json`, or a publish token. +## Security and capacity -## How it works +Artifact HTML is untrusted. It runs in an iframe sandbox without `allow-same-origin` and cannot access the authenticated shell, but scripts, forms, popups, and outbound network requests are currently allowed inside the artifact. An artifact can transmit data embedded in its own HTML. Downloaded HTML is no longer sandboxed if you open it directly. Do not publish secrets, credentials, private source, or regulated data without reviewing the generated page. + +The publishing token is deployment-wide owner automation authority. Anyone holding it can create artifacts and replace an artifact whose slug they know. Keep it only on trusted owner machines; do not distribute it as a consumer credential. -The project is a small Lakebed capsule. Lakebed supplies Google authentication, storage, and hosting. Artifact HTML is split into database-safe chunks and rendered in a sandboxed iframe without `allow-same-origin`, keeping it isolated from the authenticated application. +Lakebed currently limits capsule state to 1 MiB. This project limits one artifact to 512 KiB and individual chunks to 48 KiB, but metadata, access grants, and indexes also consume state. Treat the deployment as a small visual-document workspace, not general hosting. Delete superseded artifacts and monitor usage with Lakebed inspection tools. + +Public artifacts are subject to the [Lakebed Acceptable Use Policy](https://lakebed.dev/acceptable-use). The deployment owner is responsible for its published content and recipients. + +## Local development + +```sh +npm test +npm run build +npm run dev +``` + +Lakebed local state resets when the dev process restarts. Real Google sign-in accepts configured owner and artifact invitations; automation publishing continues to use `PUBLISH_TOKEN`. + +## How it works -The current 512 KiB artifact limit makes this a good fit for self-contained reports and visual documents rather than a general file-hosting service. +The project is a Lakebed v0 capsule. Lakebed supplies first-party Google authentication, transactional storage, and hosting. Artifact HTML is split into database-safe chunks. Owner and recipient invitations bind to durable Lakebed user IDs on first matching sign-in. HTML is rendered with `srcDoc` in a sandboxed iframe without `allow-same-origin`. diff --git a/package.json b/package.json index d36bcc8..d1fbac0 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "check": "npm run test && npm run build", "dev": "npx lakebed@0.0.29 dev", "deploy": "npx lakebed@0.0.29 deploy", + "setup": "node scripts/setup.mjs", "test": "node --test test/*.test.mjs" } } diff --git a/scripts/setup.mjs b/scripts/setup.mjs new file mode 100644 index 0000000..1b373c1 --- /dev/null +++ b/scripts/setup.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node + +import { randomBytes } from "node:crypto"; +import { chmod, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { spawnSync } from "node:child_process"; +import { parseEnv, validateBaseUrl } from "../skills/codex-artifacts/scripts/publisher-core.mjs"; + +const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const SERVER_ENV_PATH = join(PROJECT_ROOT, ".env.lakebed.server"); +const LAKEBED_PACKAGE = "lakebed@0.0.29"; + +function usage() { + return `Usage: + npm run setup -- --owner you@example.com[,another@example.com] + npm run setup + +Options: + --owner Configured owner invitations. Repeat or use commas. + --skip-login Use an existing LAKEBED_TOKEN or saved Lakebed login. + --help, -h Show this help. + +The first run writes .env.lakebed.server with mode 0600, authenticates with +Lakebed, creates an owned deployment, verifies its health, and saves its URL. +Re-running setup updates the same deployment without replacing existing secrets.`; +} + +export function parseSetupArguments(args) { + const owners = []; + let skipLogin = false; + let help = false; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--owner") { + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error("--owner requires one or more email addresses."); + } + owners.push(...value.split(",")); + index += 1; + continue; + } + if (argument === "--skip-login") { + skipLogin = true; + continue; + } + if (argument === "--help" || argument === "-h") { + help = true; + continue; + } + throw new Error(`Unknown option: ${argument}`); + } + + return { owners, skipLogin, help }; +} + +export function normalizeOwnerEmails(values) { + const emails = [...new Set(values.map((value) => value.trim().toLowerCase()).filter(Boolean))]; + for (const email of emails) { + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw new Error(`Invalid owner email: ${email}`); + } + } + return emails; +} + +export function serializeEnvironment(values) { + const preferredOrder = ["OWNER_EMAILS", "PUBLISH_TOKEN", "ARTIFACTS_URL"]; + const keys = [ + ...preferredOrder.filter((key) => values[key] !== undefined), + ...Object.keys(values) + .filter((key) => !preferredOrder.includes(key)) + .sort() + ]; + + return `${keys.map((key) => { + const value = String(values[key]); + if (/[\r\n]/.test(value)) { + throw new Error(`${key} cannot contain a newline.`); + } + return `${key}=${value}`; + }).join("\n")}\n`; +} + +async function readExistingEnvironment() { + try { + return parseEnv(await readFile(SERVER_ENV_PATH, "utf8")); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return {}; + } + throw error; + } +} + +async function writeSecureEnvironment(values) { + const temporaryPath = `${SERVER_ENV_PATH}.tmp-${process.pid}`; + await writeFile(temporaryPath, serializeEnvironment(values), { mode: 0o600 }); + await chmod(temporaryPath, 0o600); + await rename(temporaryPath, SERVER_ENV_PATH); + await chmod(SERVER_ENV_PATH, 0o600); +} + +function runLakebed(arguments_, { capture = false } = {}) { + const executable = process.platform === "win32" ? "npx.cmd" : "npx"; + const result = spawnSync(executable, [LAKEBED_PACKAGE, ...arguments_], { + cwd: PROJECT_ROOT, + encoding: "utf8", + env: process.env, + stdio: capture ? ["inherit", "pipe", "pipe"] : "inherit", + windowsHide: true, + maxBuffer: 4 * 1024 * 1024 + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + const detail = capture ? (result.stderr || result.stdout || "").trim() : ""; + throw new Error( + `Lakebed command failed: npx ${LAKEBED_PACKAGE} ${arguments_.join(" ")}` + + (detail ? `\n${detail}` : "") + ); + } + return capture ? result.stdout : ""; +} + +function parseJsonOutput(output, command) { + try { + return JSON.parse(output); + } catch { + throw new Error(`${command} returned invalid JSON.`); + } +} + +async function ensureDeveloperLogin(skipLogin) { + if (skipLogin || process.env.LAKEBED_TOKEN) { + return; + } + const status = parseJsonOutput( + runLakebed(["auth", "status", "--json"], { capture: true }), + "Lakebed auth status" + ); + if (!status.authenticated) { + runLakebed(["auth", "login"]); + } +} + +async function waitForHealth(baseUrl) { + const statusUrl = new URL("/api/status", baseUrl); + let lastError; + for (let attempt = 0; attempt < 10; attempt += 1) { + try { + const response = await fetch(statusUrl, { signal: AbortSignal.timeout(5_000) }); + const body = await response.json(); + if (response.ok && body?.ok === true && body?.service === "codex-artifacts") { + return; + } + lastError = new Error(`Health check returned HTTP ${response.status}.`); + } catch (error) { + lastError = error; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 1_000)); + } + throw new Error( + `Deployment completed, but ${statusUrl} did not become healthy: ` + + (lastError instanceof Error ? lastError.message : "unknown error") + ); +} + +async function main() { + const options = parseSetupArguments(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + + const existing = await readExistingEnvironment(); + const requestedOwners = normalizeOwnerEmails(options.owners); + const configuredOwners = requestedOwners.length + ? requestedOwners + : normalizeOwnerEmails((existing.OWNER_EMAILS ?? "").split(",")); + if (!configuredOwners.length) { + throw new Error("At least one --owner email is required on the first setup run."); + } + + const configuration = { + ...existing, + OWNER_EMAILS: configuredOwners.join(","), + PUBLISH_TOKEN: existing.PUBLISH_TOKEN || randomBytes(32).toString("hex") + }; + await writeSecureEnvironment(configuration); + + await ensureDeveloperLogin(options.skipLogin); + const deployed = parseJsonOutput( + runLakebed(["deploy", ".", "--json"], { capture: true }), + "Lakebed deploy" + ); + if (deployed.claimed !== true || typeof deployed.url !== "string") { + throw new Error("Lakebed did not create or update an owned deployment."); + } + + const existingUrl = + existing.ARTIFACTS_URL && !existing.ARTIFACTS_URL.includes("your-artifacts.lakebed.app") + ? validateBaseUrl(existing.ARTIFACTS_URL) + : undefined; + configuration.ARTIFACTS_URL = existingUrl ?? validateBaseUrl(deployed.url); + await writeSecureEnvironment(configuration); + await waitForHealth(configuration.ARTIFACTS_URL); + + console.log(`Service URL: ${configuration.ARTIFACTS_URL}`); + console.log(`Server and publisher config: ${SERVER_ENV_PATH}`); + console.log("Deployment is owned, healthy, and ready for private publishing."); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : "Setup failed."); + process.exitCode = 1; + }); +} diff --git a/test/setup.test.mjs b/test/setup.test.mjs new file mode 100644 index 0000000..56f9478 --- /dev/null +++ b/test/setup.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + normalizeOwnerEmails, + parseSetupArguments, + serializeEnvironment +} from "../scripts/setup.mjs"; + +test("parses repeated and comma-separated owner invitations", () => { + assert.deepEqual( + parseSetupArguments([ + "--owner", + "one@example.com,two@example.com", + "--owner", + "three@example.com", + "--skip-login" + ]), + { + owners: ["one@example.com", "two@example.com", "three@example.com"], + skipLogin: true, + help: false + } + ); +}); + +test("normalizes and deduplicates owner emails", () => { + assert.deepEqual( + normalizeOwnerEmails([" Owner@Example.com ", "owner@example.com", "other@example.com"]), + ["owner@example.com", "other@example.com"] + ); + assert.throws(() => normalizeOwnerEmails(["not-an-email"]), /Invalid owner email/); +}); + +test("serializes primary settings first and rejects multiline values", () => { + assert.equal( + serializeEnvironment({ + ARTIFACTS_URL: "https://artifacts.example.com", + EXTRA: "preserved", + PUBLISH_TOKEN: "secret", + OWNER_EMAILS: "owner@example.com" + }), + [ + "OWNER_EMAILS=owner@example.com", + "PUBLISH_TOKEN=secret", + "ARTIFACTS_URL=https://artifacts.example.com", + "EXTRA=preserved", + "" + ].join("\n") + ); + assert.throws( + () => serializeEnvironment({ OWNER_EMAILS: "owner@example.com\nsecond@example.com" }), + /cannot contain a newline/ + ); +}); From 787572728864cab828e9fe2c9f19814a67a6f55b Mon Sep 17 00:00:00 2001 From: none23 Date: Sun, 26 Jul 2026 17:47:42 +0400 Subject: [PATCH 4/6] Add open source release safeguards --- .github/dependabot.yml | 12 + .github/workflows/ci.yml | 23 ++ .gitignore | 2 + CONTRIBUTING.md | 30 +++ LICENSE | 21 ++ README.md | 9 +- SECURITY.md | 22 ++ openapi.yaml | 130 +++++++++ package-lock.json | 562 +++++++++++++++++++++++++++++++++++++++ package.json | 18 +- scripts/setup.mjs | 12 +- 11 files changed, 832 insertions(+), 9 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 SECURITY.md create mode 100644 openapi.yaml create mode 100644 package-lock.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..bb87cef --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a4ce8dc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run check diff --git a/.gitignore b/.gitignore index 70f2973..efb46e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .lakebed/ .env.lakebed.server +.env.lakebed.server.tmp-* lakebed.json node_modules/ +codex-artifacts-backup*.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c0b80d4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing + +## Development + +Use Node.js 20 or later: + +```sh +npm ci +npm run check +``` + +`npm run check` runs the dependency-free Node test suite and builds the Lakebed capsule. Run it before every commit. + +Application code belongs in `server/`, `client/`, and `shared/`. Capsule code may use Lakebed-provided modules and pure relative imports, but not Node built-ins or arbitrary runtime npm dependencies. Node built-ins are allowed in repository scripts and tests. + +## Security and privacy invariants + +- New artifacts remain private until access is explicitly saved. +- Authorization stays server-side and uses durable Lakebed user IDs after invitation acceptance. +- Only deployment owners may publish, replace, delete, or change access. +- Republishing preserves omitted exact-email, domain, and public settings. +- The artifact iframe must never gain `allow-same-origin`. +- HTML chunks remain below Lakebed's value limit and total state stays below the deployment limit. +- Tests, examples, commits, and issue reports contain no real credentials, private artifact URLs, or personal data. + +## Pull requests + +Keep changes focused and explain user-visible behavior, security impact, schema migration behavior, and verification. Add regression tests for changes to access control, publisher configuration, CLI parsing, chunking, or deployment setup. + +For security issues, follow [SECURITY.md](SECURITY.md) instead of opening a public pull request first. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b2a0be8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 none23 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4c0862c..aded650 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ You need Node.js 20 or later, a Google account, and a free [Lakebed](https://lak ```sh git clone https://github.com/none23/codex-artifacts.git cd codex-artifacts +npm ci npm run setup -- --owner you@example.com ``` @@ -113,7 +114,7 @@ ARTIFACTS_URL=https://your-artifacts.lakebed.app Generate the token with `openssl rand -hex 32`. Authenticate before the first deployment so Lakebed creates an owned app: ```sh -npx lakebed@0.0.29 auth login +npm exec lakebed -- auth login npm run deploy ``` @@ -133,6 +134,8 @@ Set `ARTIFACTS_URL` to the deployed or custom URL. `lakebed.json` is intentional Never commit `.env.lakebed.server`, `.lakebed/`, `lakebed.json`, or publishing/deployment tokens. +The owner automation contract is documented in [openapi.yaml](openapi.yaml). + ## Operations ### Update safely @@ -151,7 +154,7 @@ Read the deploy ID from the ignored `lakebed.json`, then export: ```sh DEPLOY_ID="$(node -p "JSON.parse(require('fs').readFileSync('lakebed.json')).deployId")" -npx lakebed@0.0.29 db export "$DEPLOY_ID" --out codex-artifacts-backup.json +npm exec lakebed -- db export "$DEPLOY_ID" --out codex-artifacts-backup.json ``` Lakebed export is not a point-in-time snapshot during concurrent writes. Keep backups private: they contain artifact HTML, owner invitations, and recipient access data. @@ -187,3 +190,5 @@ Lakebed local state resets when the dev process restarts. Real Google sign-in ac ## How it works The project is a Lakebed v0 capsule. Lakebed supplies first-party Google authentication, transactional storage, and hosting. Artifact HTML is split into database-safe chunks. Owner and recipient invitations bind to durable Lakebed user IDs on first matching sign-in. HTML is rendered with `srcDoc` in a sandboxed iframe without `allow-same-origin`. + +Codex Artifacts is an independent project and is not affiliated with or endorsed by OpenAI or Anthropic. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..84b4860 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,22 @@ +# Security policy + +## Supported version + +Security fixes are made on the default branch. This project has not yet published a stable compatibility or long-term-support policy. + +## Report a vulnerability + +Use **Security → Report a vulnerability** in the GitHub repository so details remain private. If private vulnerability reporting is unavailable, open a minimal issue asking the maintainer to establish a private channel; do not include exploit details, credentials, private artifact URLs, or personal data in a public issue. + +Include: + +- Affected revision and deployment mode +- Reproduction steps or a proof of concept +- Expected and observed impact +- Any suggested mitigation + +Do not test against a deployment you do not own or have explicit permission to assess. + +## Deployment secrets + +Publishing tokens, Lakebed tokens, `.env.lakebed.server`, `.lakebed/`, database exports, and private artifact URLs must not be committed or included in reports. Rotate a token immediately if it is exposed. diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..c9dff61 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,130 @@ +openapi: 3.1.0 +info: + title: Codex Artifacts publishing API + version: 0.1.0 + description: Owner automation API for one self-hosted Codex Artifacts deployment. + license: + name: MIT + identifier: MIT +servers: + - url: https://your-artifacts.lakebed.app +paths: + /api/status: + get: + operationId: getStatus + summary: Check service liveness + responses: + "200": + description: Service process is responding. + content: + application/json: + schema: + $ref: "#/components/schemas/Status" + /api/artifacts: + post: + operationId: publishArtifact + summary: Create an artifact or replace the artifact with a requested slug + description: | + The bearer token is deployment-wide owner automation authority. If `slug` + identifies an existing artifact, its HTML and title are replaced. Omitted + `sharedWith` and `isPublic` values preserve existing settings during updates. + security: + - publishToken: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PublishRequest" + responses: + "200": + description: Existing artifact updated. + content: + application/json: + schema: + $ref: "#/components/schemas/PublishResponse" + "201": + description: Artifact created. + content: + application/json: + schema: + $ref: "#/components/schemas/PublishResponse" + "400": + description: Invalid artifact input or exhausted state capacity. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Missing or invalid publishing token. + content: + text/plain: + schema: + type: string + "503": + description: Publishing automation is not configured. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + securitySchemes: + publishToken: + type: http + scheme: bearer + schemas: + Status: + type: object + additionalProperties: false + required: [ok, service] + properties: + ok: + type: boolean + const: true + service: + type: string + const: codex-artifacts + PublishRequest: + type: object + additionalProperties: false + required: [title, html] + properties: + title: + type: string + minLength: 1 + maxLength: 120 + slug: + type: string + description: Optional stable slug. Non-alphanumeric runs normalize to hyphens. + maxLength: 80 + html: + type: string + description: Self-contained UTF-8 HTML, limited to 512 KiB by the server. + sharedWith: + type: array + maxItems: 50 + items: + type: string + format: email + isPublic: + type: boolean + PublishResponse: + type: object + additionalProperties: false + required: [id, slug, updated, isPublic] + properties: + id: + type: string + slug: + type: string + updated: + type: boolean + isPublic: + type: boolean + Error: + type: object + additionalProperties: false + required: [error] + properties: + error: + type: string diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d216faf --- /dev/null +++ b/package-lock.json @@ -0,0 +1,562 @@ +{ + "name": "codex-artifacts", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codex-artifacts", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "lakebed": "0.0.29" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/lakebed": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/lakebed/-/lakebed-0.0.29.tgz", + "integrity": "sha512-jxUtQcBNTtXR/Ta/EsOsiNtC/eBq8ETwQRzficImULOVbUmBSayhQP8TrpHeDkcFCd+pcV4kPivdixuAsFl9TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.1", + "preact": "^10.28.0", + "ws": "^8.21.0" + }, + "bin": { + "lakebed": "bin/lakebed.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/preact": { + "version": "10.29.7", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz", + "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json index d1fbac0..f50f3d2 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,25 @@ "name": "codex-artifacts", "version": "0.1.0", "private": true, + "description": "Self-hosted private publishing for agent-generated HTML artifacts", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/none23/codex-artifacts.git" + }, + "engines": { + "node": ">=20" + }, "type": "module", "scripts": { - "build": "npx lakebed@0.0.29 build . --target anonymous", + "build": "lakebed build . --target anonymous", "check": "npm run test && npm run build", - "dev": "npx lakebed@0.0.29 dev", - "deploy": "npx lakebed@0.0.29 deploy", + "dev": "lakebed dev", + "deploy": "lakebed deploy", "setup": "node scripts/setup.mjs", "test": "node --test test/*.test.mjs" + }, + "devDependencies": { + "lakebed": "0.0.29" } } diff --git a/scripts/setup.mjs b/scripts/setup.mjs index 1b373c1..bc4fc4c 100644 --- a/scripts/setup.mjs +++ b/scripts/setup.mjs @@ -10,7 +10,12 @@ import { parseEnv, validateBaseUrl } from "../skills/codex-artifacts/scripts/pub const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const SERVER_ENV_PATH = join(PROJECT_ROOT, ".env.lakebed.server"); -const LAKEBED_PACKAGE = "lakebed@0.0.29"; +const LAKEBED_EXECUTABLE = join( + PROJECT_ROOT, + "node_modules", + ".bin", + process.platform === "win32" ? "lakebed.cmd" : "lakebed" +); function usage() { return `Usage: @@ -105,8 +110,7 @@ async function writeSecureEnvironment(values) { } function runLakebed(arguments_, { capture = false } = {}) { - const executable = process.platform === "win32" ? "npx.cmd" : "npx"; - const result = spawnSync(executable, [LAKEBED_PACKAGE, ...arguments_], { + const result = spawnSync(LAKEBED_EXECUTABLE, arguments_, { cwd: PROJECT_ROOT, encoding: "utf8", env: process.env, @@ -120,7 +124,7 @@ function runLakebed(arguments_, { capture = false } = {}) { if (result.status !== 0) { const detail = capture ? (result.stderr || result.stdout || "").trim() : ""; throw new Error( - `Lakebed command failed: npx ${LAKEBED_PACKAGE} ${arguments_.join(" ")}` + + `Lakebed command failed: lakebed ${arguments_.join(" ")}` + (detail ? `\n${detail}` : "") ); } From 0152006c4cffe5871445e090e69f95ade0e30206 Mon Sep 17 00:00:00 2001 From: none23 Date: Sun, 26 Jul 2026 17:49:46 +0400 Subject: [PATCH 5/6] Enforce capsule artifact capacity --- AGENTS.md | 2 +- README.md | 2 +- client/index.tsx | 11 +++++++++-- server/index.ts | 29 +++++++++++++++++++++++++++++ shared/config.ts | 1 + 5 files changed, 41 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f6f1081..7e28c53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,6 @@ This repository is a Lakebed v0 capsule. Keep application code within `server/`, - New artifacts must remain private until explicit recipient emails are saved. - Only owners may change access. Preserve exact-email, domain, and public settings when replacing or republishing HTML. - Never add `allow-same-origin` to the artifact iframe sandbox. Artifact HTML is untrusted relative to the authenticated shell. -- Keep HTML chunks below Lakebed's 64 KiB value limit and total state within its 1 MiB free capsule limit. +- Keep HTML chunks below Lakebed's 64 KiB value limit, artifact HTML within the 768 KiB application budget, and total state within Lakebed's 1 MiB capsule limit. - Keep `.env.lakebed.server` and publish tokens out of git. - Use `node scripts/publish.mjs ...` when asked to publish an HTML artifact through a configured deployment. diff --git a/README.md b/README.md index aded650..28d6157 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ Artifact HTML is untrusted. It runs in an iframe sandbox without `allow-same-ori The publishing token is deployment-wide owner automation authority. Anyone holding it can create artifacts and replace an artifact whose slug they know. Keep it only on trusted owner machines; do not distribute it as a consumer credential. -Lakebed currently limits capsule state to 1 MiB. This project limits one artifact to 512 KiB and individual chunks to 48 KiB, but metadata, access grants, and indexes also consume state. Treat the deployment as a small visual-document workspace, not general hosting. Delete superseded artifacts and monitor usage with Lakebed inspection tools. +Lakebed currently limits capsule state to 1 MiB. This project limits one artifact to 512 KiB, individual chunks to 48 KiB, and total artifact HTML to 768 KiB, reserving the remaining state for metadata, access grants, and indexes. Treat the deployment as a small visual-document workspace, not general hosting. Delete superseded artifacts and monitor usage with Lakebed inspection tools. Public artifacts are subject to the [Lakebed Acceptable Use Policy](https://lakebed.dev/acceptable-use). The deployment owner is responsible for its published content and recipients. diff --git a/client/index.tsx b/client/index.tsx index 85eb500..49de515 100644 --- a/client/index.tsx +++ b/client/index.tsx @@ -14,6 +14,7 @@ import { useEffect, useMemo, useState } from "preact/hooks"; import type app from "../server"; import { MAX_ARTIFACT_BYTES, + MAX_TOTAL_ARTIFACT_BYTES, chunkHtml, cleanSlug, isValidDomain, @@ -153,7 +154,7 @@ function NewArtifactForm() {

New artifact

Publish an HTML file

-

Only you can see it until you add verified Google emails. Maximum {formatBytes(MAX_ARTIFACT_BYTES)}.

+

Only you can see it until you add recipients. Maximum {formatBytes(MAX_ARTIFACT_BYTES)} per artifact; {formatBytes(MAX_TOTAL_ARTIFACT_BYTES)} workspace HTML budget.

void submit(event)}>