From dbc575c99d5cb9ab4a58f107bdee1557bc75a4b5 Mon Sep 17 00:00:00 2001 From: Hitesh Sisara Date: Sat, 8 Aug 2026 05:06:01 +0000 Subject: [PATCH 1/9] feat(auth): Google-only sign-in on hosted deployments Hosted deployments now require a real Google identity to mint API keys. API keys become publish-only: they can upload and read but can no longer sign in or mint further keys, so a key leaked from CI config or a dotfile cannot take over the account. Hosted mode fails closed. If OAuth were ever misconfigured, key creation returns 503 rather than silently accepting an API key -- a config regression must not quietly remove the identity requirement. Self-hosted deployments keep key-paste sign-in via AUTH_MODE, since a self-hoster may have no IdP available. --- dashboard/src/routes/login/+page.server.ts | 31 ++++++- dashboard/src/routes/login/+page.svelte | 88 ++++++++++++------- .../src/routes/settings/keys/+page.svelte | 58 +++++++----- worker/src/env.d.ts | 7 +- worker/src/index.ts | 21 +++-- worker/src/middleware/auth.ts | 46 +++++++++- worker/src/routes/keys.ts | 9 +- worker/src/routes/session.ts | 11 +++ worker/worker-configuration.d.ts | 7 +- worker/wrangler.jsonc | 6 +- 10 files changed, 211 insertions(+), 73 deletions(-) diff --git a/dashboard/src/routes/login/+page.server.ts b/dashboard/src/routes/login/+page.server.ts index 482b0d1..654abc5 100644 --- a/dashboard/src/routes/login/+page.server.ts +++ b/dashboard/src/routes/login/+page.server.ts @@ -13,17 +13,42 @@ export const load: PageServerLoad = async ({ locals, url }) => { "/api/config", ); if (res.ok) { - const cfg = (await res.json()) as { google_oauth_enabled?: boolean }; - return { googleEnabled: !!cfg.google_oauth_enabled, googleError }; + const cfg = (await res.json()) as { + google_oauth_enabled?: boolean; + auth_mode?: string; + }; + return { + googleEnabled: !!cfg.google_oauth_enabled, + // hosted => Google is the ONLY door; the key-paste form is not rendered at all. + hosted: cfg.auth_mode === "hosted", + googleError, + }; } } catch { /* config is advisory; fall back to key-only sign-in */ } - return { googleEnabled: false, googleError }; + return { googleEnabled: false, hosted: false, googleError }; }; export const actions: Actions = { default: async ({ request, cookies, locals }) => { + // Hiding the form is not enforcement — a hand-crafted POST must be refused too. + // The API rejects this as well; this is defence in depth at the BFF boundary. + try { + const cfgRes = await apiFetch( + { apiBase: locals.apiBase, apiKey: null, service: locals.apiService }, + "/api/config", + ); + if (cfgRes.ok) { + const cfg = (await cfgRes.json()) as { auth_mode?: string }; + if (cfg.auth_mode === "hosted") { + return fail(403, { message: "Sign in with Google. API-key sign-in is disabled here." }); + } + } + } catch { + /* config is advisory; the API enforces the same rule regardless */ + } + const form = await request.formData(); const key = String(form.get("api_key") ?? "").trim(); if (!key.startsWith("ad_")) { diff --git a/dashboard/src/routes/login/+page.svelte b/dashboard/src/routes/login/+page.svelte index f41659f..9463a56 100644 --- a/dashboard/src/routes/login/+page.svelte +++ b/dashboard/src/routes/login/+page.svelte @@ -1,48 +1,68 @@

SIGN IN.

-

- Paste a scoped API key. It is stored in an httpOnly cookie on this origin and is never - exposed to browser scripts. -

-
-
api key
-
-
- - - {#if form?.message}

{form.message}

{/if} -
- -
-
+{#if data?.hosted} +

+ Sign in with Google to reach your dashboard, publish drafts, and create an API key for + each machine your agents run on. +

+ +
+
continue with google
+
+ {#if errorText} +

{errorText}

+ {/if} + Sign in with Google +

+ API keys are machine credentials. They publish drafts but cannot sign in here or + create further keys — so a key leaked from a CI config or a dotfile can never be + used to take over the account. +

+
-
+{:else} + +

+ Paste a scoped API key. It is stored in an httpOnly cookie on this origin and is never + exposed to browser scripts. +

-{#if data?.googleEnabled} -
-

Or continue with Google

-

- Creating API keys requires a verified Google identity, so a leaked key cannot mint - more keys. -

- Sign in with Google +
+
api key
+
+
+ + + {#if form?.message}

{form.message}

{/if} +
+ +
+
+
-{/if} -{#if data?.googleError} -

- Google sign-in did not complete ({data.googleError}). Try again, or use an API key. -

+ {#if data?.googleEnabled} +
+

Or continue with Google

+ Sign in with Google +
+ {/if} {/if} - -

- No key yet? The instance owner bootstraps the first key via - POST /api/bootstrap, then mints scoped keys on the Keys page. -

diff --git a/dashboard/src/routes/settings/keys/+page.svelte b/dashboard/src/routes/settings/keys/+page.svelte index 7ee8665..6674ec1 100644 --- a/dashboard/src/routes/settings/keys/+page.svelte +++ b/dashboard/src/routes/settings/keys/+page.svelte @@ -6,41 +6,51 @@

API KEYS.

-

- Keys authenticate the CLI and your agents. The full value is shown once at creation and - cannot be retrieved later. Minting keys requires the manage scope. +

+ Create one key per machine your agents run on — laptop, remote box, each + CI runner. Naming them per machine means you can revoke a single machine without + touching the others. The full value is shown once at creation and cannot be retrieved + later.

{#if !data.canManage}
-

The signed-in key does not have the manage scope.

+

This session cannot manage keys.

- Sign in with a manage-scoped key (or the bootstrap key) to administer keys. + Keys can only be created from the dashboard after signing in with Google.

{:else} {#if form?.newKey}
-
new key — copy it now
+
new key for “{form.name}” — copy it now
-

This is the only time the full key is shown.

+

+ This is the only time the full key is shown. Store it on that machine and it + never needs to be seen again. +

(e.currentTarget as HTMLInputElement).select()} /> +

Then, on that machine:

+
agentdraft auth set {form.newKey}
{/if} -
-
create key
+
+
new machine key
- - - Scopes + + +

+ Use something you will recognise months from now when deciding what to revoke. +

+ Scopes
+

+ Machine keys publish and read. They cannot sign in to this dashboard or create + further keys, so a leaked key can never take over the account. +

{#if form?.message}

{form.message}

{/if}
@@ -59,13 +70,13 @@
-

Your keys

+

Your machines

{#if data.keys.length === 0} -

No keys yet.

+

No keys yet. Create one above for the first machine.

{:else} - + {#each data.keys as k} @@ -74,7 +85,7 @@
NamePrefixScopesLast usedStatus
MachinePrefixScopesLast usedStatus
{k.key_prefix}… {k.scopes} - {k.last_used_at ? new Date(k.last_used_at + "Z").toLocaleString() : "—"} + {k.last_used_at ? new Date(k.last_used_at + "Z").toLocaleString() : "never"} {#if k.revoked_at} @@ -85,7 +96,14 @@ {#if !k.revoked_at} -
+ { + if (!confirm(`Revoke the key for “${k.name}”? That machine stops publishing immediately.`)) + e.preventDefault(); + }} + >
diff --git a/worker/src/env.d.ts b/worker/src/env.d.ts index ffb55a4..6277e1c 100644 --- a/worker/src/env.d.ts +++ b/worker/src/env.d.ts @@ -22,7 +22,12 @@ export interface AgentDraftSecrets { // runtime comparison against "true" a compile error even though the value is meant to // be reconfigured per deployment. Widen the configurable vars back to string; the // binding types (D1/R2/KV) stay exactly as generated. -type ConfigurableVars = "ENVIRONMENT" | "CONTENT_BASE_URL" | "DASHBOARD_ORIGIN" | "GOOGLE_OAUTH_ENABLED"; +type ConfigurableVars = + | "ENVIRONMENT" + | "CONTENT_BASE_URL" + | "DASHBOARD_ORIGIN" + | "GOOGLE_OAUTH_ENABLED" + | "AUTH_MODE"; export type Env = Omit & Record & diff --git a/worker/src/index.ts b/worker/src/index.ts index 460160f..4df9545 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -2,7 +2,12 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import type { Env } from "./env.js"; import type { AuthContext } from "./types.js"; -import { requireAuth, requireScope, requireGoogleIdentity } from "./middleware/auth.js"; +import { + requireAuth, + requireScope, + requireGoogleIdentity, + requireHumanSession, +} from "./middleware/auth.js"; import { rateLimit } from "./middleware/ratelimit.js"; import { jsonError } from "./lib/http.js"; import uploadRoute from "./routes/upload.js"; @@ -47,6 +52,7 @@ app.get("/api/config", (c) => c.json({ ok: true, google_oauth_enabled: c.env.GOOGLE_OAUTH_ENABLED === "true", + auth_mode: c.env.AUTH_MODE === "hosted" ? "hosted" : "self-hosted", content_base_url: c.env.CONTENT_BASE_URL, }), ); @@ -93,11 +99,14 @@ app.route("/api/drafts", draftsRoute); // from escalating to mint new keys, while allowing a manage-scoped key (or a dashboard // session, which carries manage) to administer keys. The dashboard uses a BFF that // holds a manage key server-side; /api/session remains a valid alternative integration. -app.use("/api/api-keys/*", requireAuth(), requireScope("manage"), rateLimit("read", 1000, 3600)); -app.use("/api/api-keys", requireAuth(), requireScope("manage"), rateLimit("read", 1000, 3600)); -// Minting a key is the only operation that creates durable NEW access, so it carries an -// extra gate: a verified Google identity. Listing and revoking deliberately do NOT — -// you must always be able to revoke a key, including when your IdP is unavailable. +// Key management is DASHBOARD-ONLY in hosted mode: requireHumanSession rejects API-key +// bearer auth outright, so a machine credential can publish but never administer the +// account. On a self-hosted instance with no IdP these middlewares no-op, keeping that +// deployment usable. +app.use("/api/api-keys/*", requireAuth(), requireScope("manage"), requireHumanSession(), rateLimit("read", 1000, 3600)); +app.use("/api/api-keys", requireAuth(), requireScope("manage"), requireHumanSession(), rateLimit("read", 1000, 3600)); +// Minting additionally requires a verified Google identity, and FAILS CLOSED in hosted +// mode if OAuth is misconfigured rather than silently accepting a key. app.post("/api/api-keys", requireGoogleIdentity()); app.route("/api/api-keys", keysRoute); diff --git a/worker/src/middleware/auth.ts b/worker/src/middleware/auth.ts index ba2feb5..eb6d23f 100644 --- a/worker/src/middleware/auth.ts +++ b/worker/src/middleware/auth.ts @@ -120,12 +120,29 @@ export function requireSession() { // plainly in SECURITY.md rather than implied to be absolute. export function requireGoogleIdentity() { return async (c: Context<{ Bindings: Env; Variables: { auth: AuthContext } }>, next: Next) => { + const hosted = c.env.AUTH_MODE === "hosted"; const oauthOn = c.env.GOOGLE_OAUTH_ENABLED === "true" && !!c.env.GOOGLE_CLIENT_ID && !!c.env.GOOGLE_CLIENT_SECRET; - if (!oauthOn) { + + // HOSTED mode fails CLOSED. If OAuth is somehow misconfigured on the hosted + // deployment we must refuse to mint keys, not silently fall back to accepting an + // API key — a config regression must not quietly remove the identity requirement. + if (hosted && !oauthOn) { + return jsonError( + c, + 503, + "E_OAUTH_MISCONFIGURED", + "Google sign-in is required but not configured. Key creation is disabled.", + ); + } + + // SELF-HOSTED without an IdP: the gate is disabled by design, otherwise the + // instance could never mint its first key and would be unusable. + if (!hosted && !oauthOn) { await next(); return; } + const auth = c.get("auth"); if (!auth) return jsonError(c, 401, "E_UNAUTHENTICATED", "Not authenticated."); if (auth.authMethod !== "google") { @@ -133,7 +150,32 @@ export function requireGoogleIdentity() { c, 403, "E_GOOGLE_SIGNIN_REQUIRED", - "Creating API keys requires signing in with Google.", + "API keys can only be created from the dashboard after signing in with Google.", + ); + } + await next(); + }; +} + +// HOSTED mode: reject API-key bearer auth on DASHBOARD/management surfaces. +// +// An API key is a machine credential. In hosted mode humans arrive via Google, so a +// key must not be usable to browse or administer the account — that keeps a leaked +// key confined to publishing, which is all a machine needs. +export function requireHumanSession() { + return async (c: Context<{ Bindings: Env; Variables: { auth: AuthContext } }>, next: Next) => { + if (c.env.AUTH_MODE !== "hosted") { + await next(); + return; + } + const auth = c.get("auth"); + if (!auth) return jsonError(c, 401, "E_UNAUTHENTICATED", "Not authenticated."); + if (auth.via !== "session" || auth.authMethod !== "google") { + return jsonError( + c, + 403, + "E_GOOGLE_SIGNIN_REQUIRED", + "This endpoint requires a Google-signed-in dashboard session.", ); } await next(); diff --git a/worker/src/routes/keys.ts b/worker/src/routes/keys.ts index 9d374f7..a6f4709 100644 --- a/worker/src/routes/keys.ts +++ b/worker/src/routes/keys.ts @@ -11,7 +11,7 @@ const keys = new Hono<{ Bindings: Env; Variables: { auth: AuthContext } }>(); keys.get("/", async (c) => { const auth = c.get("auth"); const rows = await c.env.DB.prepare( - "SELECT id, name, key_prefix, scopes, last_used_at, revoked_at, expires_at, created_at FROM api_keys WHERE account_id = ? ORDER BY created_at DESC", + "SELECT id, name, key_prefix, scopes, last_used_at, revoked_at, expires_at, created_via, created_at FROM api_keys WHERE account_id = ? ORDER BY created_at DESC", ) .bind(auth.account.id) .all(); @@ -35,10 +35,13 @@ keys.post("/", async (c) => { const keyId = newId("key_"); const { full, prefix } = newApiKey(); const hash = await hashApiKey(full, c.env.API_KEY_PEPPER ?? ""); + // created_via records HOW the key was minted, so provenance is auditable later: + // "google-dashboard" for a Google-verified human session, otherwise the auth method. + const createdVia = auth.authMethod === "google" ? "google-dashboard" : `${auth.via}:${auth.authMethod}`; await c.env.DB.prepare( - "INSERT INTO api_keys (id, account_id, name, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?)", + "INSERT INTO api_keys (id, account_id, name, key_prefix, key_hash, scopes, created_via) VALUES (?, ?, ?, ?, ?, ?, ?)", ) - .bind(keyId, auth.account.id, name, prefix, hash, scopes.join(",")) + .bind(keyId, auth.account.id, name, prefix, hash, scopes.join(","), createdVia) .run(); return c.json( { ok: true, key_id: keyId, name, key_prefix: prefix, scopes, api_key: full, note: "Shown once." }, diff --git a/worker/src/routes/session.ts b/worker/src/routes/session.ts index cb20f81..6eadd41 100644 --- a/worker/src/routes/session.ts +++ b/worker/src/routes/session.ts @@ -19,6 +19,17 @@ session.post("/", async (c) => { const body = parsedBody.body; const badField = badStringField(c, body, ["api_key"]); if (badField) return badField; + // HOSTED mode: Google is the only way in. Exchanging an API key for a dashboard + // session here would be a second door bypassing Google entirely. + if (c.env.AUTH_MODE === "hosted") { + return jsonError( + c, + 403, + "E_GOOGLE_SIGNIN_REQUIRED", + "Sign in with Google. API-key sign-in is disabled on this deployment.", + ); + } + const apiKey = typeof body.api_key === "string" ? body.api_key.trim() : ""; if (!apiKey.startsWith("ad_")) { return jsonError(c, 400, "E_BAD_KEY", "Provide a valid ad_ API key."); diff --git a/worker/worker-configuration.d.ts b/worker/worker-configuration.d.ts index ca1a113..78c104f 100644 --- a/worker/worker-configuration.d.ts +++ b/worker/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: b705a8992ab4684c9d7baa8d741144de) +// Generated by Wrangler by running `wrangler types` (hash: 2336eda563a5b122334bfdbb49393570) // Runtime types generated with workerd@1.20260601.1 2026-08-01 nodejs_compat interface __BaseEnv_Env { RATELIMIT: KVNamespace; @@ -8,7 +8,8 @@ interface __BaseEnv_Env { ENVIRONMENT: "production"; CONTENT_BASE_URL: "https://agentdraft.tapetide.com"; DASHBOARD_ORIGIN: "https://app.agentdraft.tapetide.com"; - GOOGLE_OAUTH_ENABLED: "false"; + GOOGLE_OAUTH_ENABLED: "true"; + AUTH_MODE: "hosted"; } declare namespace Cloudflare { interface GlobalProps { @@ -21,7 +22,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/worker/wrangler.jsonc b/worker/wrangler.jsonc index 6e630b5..b1ef2a8 100644 --- a/worker/wrangler.jsonc +++ b/worker/wrangler.jsonc @@ -10,7 +10,11 @@ // content worker is first deployed and its workers.dev URL is known. "CONTENT_BASE_URL": "https://agentdraft.tapetide.com", "DASHBOARD_ORIGIN": "https://app.agentdraft.tapetide.com", - "GOOGLE_OAUTH_ENABLED": "false" + "GOOGLE_OAUTH_ENABLED": "true", + // "hosted" -> Google sign-in is the ONLY way in; API keys can be minted + // exclusively from the dashboard by a Google-verified session. + // "self-hosted" -> API-key paste is also accepted (a self-hoster may have no IdP). + "AUTH_MODE": "hosted" }, "d1_databases": [ { From 9831f75ef42b9788f463bf473be23c6d85ea0143 Mon Sep 17 00:00:00 2001 From: Hitesh Sisara Date: Sat, 8 Aug 2026 06:01:56 +0000 Subject: [PATCH 2/9] docs(skills): ship an agent skill for publishing drafts Left to guess, an agent invents flags that don't exist, publishes a revision as a second draft and strands the reviewer on a dead link, reaches for an external stylesheet the HTML policy rejects, or hands back a /tmp path instead of a URL. This encodes the behaviour that works. Every command, flag, error code, and exit code is verified against the real CLI and live API rather than written from the docs, including two behaviours the docs do not state: --new rewrites the local path->draft mapping so the previous draft is orphaned, and an upload with unchanged content still creates a new version because dedupe is by idempotency key and not by content hash. --- README.md | 2 +- skills/README.md | 40 +++++ skills/agentdraft-publish-draft/SKILL.md | 188 +++++++++++++++++++++++ 3 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 skills/README.md create mode 100644 skills/agentdraft-publish-draft/SKILL.md diff --git a/README.md b/README.md index 67d0dd0..47ffc60 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Open source by **[Tapetide](https://tapetide.com/)** · MIT licensed · runs on Cloudflare's free tier -[Quick start](#quick-start) · [How it works](#how-it-works) · [CLI](./docs/cli.md) · [API](./docs/api.md) · [Security model](./SECURITY.md) · [Self-hosting](./docs/self-hosting.md) +[Quick start](#quick-start) · [How it works](#how-it-works) · [CLI](./docs/cli.md) · [API](./docs/api.md) · [Agent skills](./skills/) · [Security model](./SECURITY.md) · [Self-hosting](./docs/self-hosting.md) diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 0000000..de71f0a --- /dev/null +++ b/skills/README.md @@ -0,0 +1,40 @@ +# Agent Skills + +Drop-in instructions that teach an AI coding agent how to use `agentdraft` correctly. + +A skill is a single `SKILL.md`: YAML frontmatter (name, description, trigger) plus a +markdown body with commands, constraints, and pitfalls. The format originated with +[Claude Code](https://code.claude.com/docs/en/skills) and is read by several agent +runtimes, including Hermes Agent and Cursor. + +## Available skills + +| Skill | Use when | +|---|---| +| [`agentdraft-publish-draft`](./agentdraft-publish-draft/SKILL.md) | A human needs to read agent output in a browser — plans, proposals, reports, audits | + +## Install + +Copy the skill directory into wherever your agent looks for skills: + +```bash +# Claude Code (project-scoped) +mkdir -p .claude/skills && cp -r skills/agentdraft-publish-draft .claude/skills/ + +# Hermes Agent (user-scoped) +cp -r skills/agentdraft-publish-draft ~/.hermes/skills/ +``` + +Most runtimes cache the skill index at session start, so start a new session before +expecting the agent to see a newly copied skill. + +## Why ship these + +Left to guess, an agent will invent flags that don't exist, publish a revision as a second +draft and strand the reviewer on a dead link, reach for an external stylesheet that the +HTML policy rejects, or hand back a `/tmp` path instead of a URL. The skill encodes the +behaviour that actually works, so nobody has to rediscover it. + +Every command, flag, error code, and exit code in these skills is verified against the +real CLI and live API — not written from the docs. If you change CLI behaviour, update the +matching skill in the same PR. diff --git a/skills/agentdraft-publish-draft/SKILL.md b/skills/agentdraft-publish-draft/SKILL.md new file mode 100644 index 0000000..0d7234f --- /dev/null +++ b/skills/agentdraft-publish-draft/SKILL.md @@ -0,0 +1,188 @@ +--- +name: agentdraft-publish-draft +description: Use when a human needs to read agent output in a browser. Publishes plans, proposals, reports, and audits to stable versioned URLs via the agentdraft CLI. +version: 1.0.0 +author: Tapetide +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [agentdraft, publishing, review, cli, handoff] + related_skills: [] +--- + +# Publishing Drafts with agentdraft + +## Overview + +You produce work a human needs to read — a migration plan, an architecture proposal, a +security audit, a refactor report. That work is useless if it lands in `/tmp` on a box +nobody can reach, or gets flattened into a chat window, or expires as a CI artifact. + +`agentdraft upload ` returns a URL a human opens in a browser. One command, no build +step. Every re-upload of the same file becomes a **new immutable version** at the **same +stable URL**, so a reviewer's link never goes stale and earlier versions stay readable. + +## When to Use + +- You wrote a plan, proposal, report, or audit that a human must review before you proceed +- You need a durable link to hand off: a PR comment, a chat message, a ticket +- You are on a machine the human cannot reach (remote box, CI runner, ephemeral container) +- You are iterating on a document and want the reviewer's link to keep working + +Don't use for: + +- Source code changes — those belong in a commit and a PR +- Secrets, credentials, tokens, or customer PII. **Published drafts are public to anyone + holding the URL.** There is no per-viewer access control. +- Anything needing JavaScript. Scripts are rejected (see Constraints). + +## Publish in one command + +```bash +agentdraft upload plan.md +# URL: https://agentdraft.tapetide.com/d/6146mnh523xb +# Draft: 6146mnh523xb +# Version: 1 +# Title: Agent Plan +``` + +Markdown (`.md`, `.markdown`, `.mdown`, `.mkd`) is rendered server-side for browser +reading; the exact source stays retrievable. HTML is served byte-for-byte. Give the file a +real `# Heading` or `` — that becomes the draft title the human sees in a list. + +Report the **URL** to the human. Never report only a local path. + +## Updating: the same path updates the same draft + +The CLI maps each absolute file path to a draft id in `~/.agentdraft/drafts.json`. Re-upload +the same path and it becomes the next version at the same URL: + +```bash +agentdraft upload plan.md # Version: 1 +# ...revise the file... +agentdraft upload plan.md # Version: 2 — same URL +``` + +The stable `/d/<id>` URL always serves the latest version, and `/d/<id>/v/1` still serves +the first. Consequences worth internalising: + +- **Revising a document?** Upload the same path. Do not create a second draft — that + strands the reviewer on a dead link. +- **Genuinely different document?** Use `--new`, or the mapping will overwrite an unrelated + draft that happened to use the same filename. +- **Fresh clone or different machine?** `drafts.json` is local, so the mapping is gone and + an upload creates a *new* draft. Pass `--draft <id>` to target the existing one. + +```bash +agentdraft upload report.md --new # always a new draft +agentdraft upload report.md --draft 6146mnh523xb # force-update a known draft +``` + +**`--new` rewrites the mapping.** After `--new`, that path points at the *new* draft, so +the next plain `upload` of the same path updates the new one and the previous draft is +orphaned — still live at its URL, but no longer reachable by uploading that file. Verified: +uploading one path twice with `--new` produced drafts A then B, and a following plain upload +hit B. If you need to keep updating A, record its id and pass `--draft A`. + +## Retrying safely + +Network retries must not create duplicate versions. Pass a stable `--idempotency-key`; a +replay returns the same version and says so: + +```bash +agentdraft upload plan.md --idempotency-key "deploy-plan-$(git rev-parse HEAD)" +# Version: 3 +# (idempotent replay — no new version created) +``` + +Use this in any unattended loop — CI, cron, a retry wrapper. + +## Grouping and reading back + +```bash +agentdraft upload plan.md --project migration-q3 # created if the name is new +agentdraft projects +agentdraft list --json --limit 20 # machine-readable +``` + +Read a draft back — how one agent consumes another's plan, no auth needed: + +```bash +agentdraft fetch 6146mnh523xb # latest, to stdout +agentdraft fetch 6146mnh523xb --version 1 # a specific earlier version +agentdraft fetch <url> -o plan.md +``` + +## Authentication + +```bash +agentdraft auth set "$KEY" # non-interactive; verifies against the server +agentdraft whoami # confirms account + scopes +``` + +Keys are created in the dashboard (Settings → API Keys). **There is no +`AGENTDRAFT_API_KEY` environment variable** — credentials come only from `auth set` or +`auth login`, stored at `~/.agentdraft/credentials.json` (`0600`). In CI, inject the key +from your secret store and run `auth set` as a setup step: + +```bash +agentdraft auth set "$AGENTDRAFT_KEY" && agentdraft upload plan.md +``` + +Keys are **publish-only**: they upload and read but cannot mint further keys, so a key +leaked from CI config cannot take over the account. Use one key per machine so revoking +one leaves the others working. + +## Constraints: what will be rejected + +Uploaded documents are served from an isolated origin and validated conservatively. Common +rejections, with the stable error code the CLI prints: + +| Code | Cause | +|---|---| +| `E_SCRIPT` | any `<script>` element | +| `E_EVENT_HANDLER` | any `on*` attribute (`onclick`, …) | +| `E_FORM` | `<form>`, `<input>`, `<textarea>`, `<select>` | +| `E_FRAME` | `<iframe>`, `<frame>`, `<portal>` | +| `E_LINK` | `<link>` — so **no external stylesheets** | +| `E_DANGEROUS_URL` | `javascript:`, `data:text/html`, `file:` in a URL attribute | +| `E_DANGEROUS_CSS` | CSS `expression()`, `behavior:`, `@import` | +| `E_TOO_LARGE` | over 2 MiB (bytes, not characters) | +| `E_ENCODING` | not valid UTF-8, or a declared non-UTF-8 charset | + +Allowed and sufficient for a good-looking document: all semantic HTML, **inline `<style>` +blocks and `style=` attributes**, `<img>` with `https:` or `data:image/*`, `<a href>` to +`https:`/`mailto:`/fragments, inline SVG, and tables. So style with an inline `<style>` +block — never a `<link>` to a CDN, and never a charting library. + +`agentdraft upload` exits **0** on success and **1** on any rejection, so `&&` chaining and +CI gates behave correctly. The server is authoritative: a local pre-check only catches +empty, oversize, and non-UTF-8 files to save a round trip. + +## Common Pitfalls + +1. **Reporting a file path instead of the URL.** The whole point is a link the human can + open. Always surface the `URL:` line. +2. **Creating a new draft for a revision.** Upload the same path. A reviewer holding the + old link must see your update, not a stale v1. +3. **Assuming `AGENTDRAFT_API_KEY` works.** It does not exist. Run `auth set` first; + otherwise every command fails `E_UNAUTHENTICATED`. +4. **Expecting the path mapping to survive.** `drafts.json` is per-machine and per-`$HOME`. + In a fresh container, pass `--draft <id>` or you will silently fork the document. +5. **Styling with an external stylesheet.** `<link>` is `E_LINK`. Inline `<style>` instead. +6. **Retrying without an idempotency key.** Every retry becomes a new version, and version + history stops meaning anything. An upload with *unchanged* content still creates a new + version — the server does not dedupe by content hash, only by idempotency key. +7. **Publishing secrets.** A URL holder is a reader. Scrub tokens, keys, internal + hostnames, and customer data before uploading. +8. **Reusing one key everywhere.** One key per machine; revocation then stays surgical. + +## Verification Checklist + +- [ ] `agentdraft whoami` shows the expected account before uploading +- [ ] Upload exited 0 and printed a `URL:` line +- [ ] For a revision: `Version:` incremented and the URL is **unchanged** from last time +- [ ] Opened or `fetch`ed the URL and confirmed it serves the intended content +- [ ] Document contains no secrets, tokens, or customer data +- [ ] The URL — not a local path — is what you reported to the human From bfebeff1a2ee4742122bf6dfced432fbd65317ef Mon Sep 17 00:00:00 2001 From: Hitesh Sisara <hitesh.dev@sisara.in> Date: Sat, 8 Aug 2026 07:54:42 +0000 Subject: [PATCH 3/9] feat(icons): proper favicon set on both hosted origins The old favicon was leftover WebHost branding: a blue #6ea8fe tile with a W squiggle, unrelated to the product palette. And only the dashboard served any icon at all -- the CONTENT origin, where humans actually open published drafts, 404'd every icon path, so every draft showed a blank globe in the tab. That is the surface reviewers see most. Content origin serves /favicon.ico and /favicon.svg at the root. It cannot inject a <link> into a document because pages are served byte-for-byte, so root-level icons are the only correct mechanism: a browser requests /favicon.ico on its own when a document declares no icon. The ICO is embedded as base64 rather than read from R2 or an assets binding, which would widen the untrusted-content origin's surface to serve 5 KB. Dashboard gains a multi-resolution ICO (16/32/48), an opaque 180px apple-touch-icon -- iOS composites a transparent one onto WHITE -- PWA icons, a maskable icon, a manifest, and a theme-color matching --bg so mobile chrome does not flash white against the dark UI. Favicon geometry is pixel-aligned for 16px rendering: bars and gaps are even units, so edges land on whole device pixels instead of antialiasing to mush. Verified numerically -- zero antialiased interior pixels, equal bar lengths, accent ratio 0.50, padding balanced 3/3. All derived assets are generated by scripts/gen-icons.sh from two SVG sources of truth, with scripts/verify_icons.py asserting 36 properties including that the base64 embedded in the worker decodes to byte-identical bytes and its 16px frame matches a fresh SVG render. That check exists because a hand-rolled embed silently corrupted the base64 -- it compiled clean and would have served a broken image. --- content-worker/src/icons.ts | 157 ++++++++++++++++++++++++ content-worker/src/index.ts | 10 ++ dashboard/src/app.html | 10 +- dashboard/static/apple-touch-icon.png | Bin 0 -> 644 bytes dashboard/static/favicon.ico | Bin 0 -> 15086 bytes dashboard/static/favicon.svg | 15 ++- dashboard/static/icon-192.png | Bin 0 -> 615 bytes dashboard/static/icon-512.png | Bin 0 -> 1956 bytes dashboard/static/icon.svg | 11 ++ dashboard/static/site.webmanifest | 16 +++ scripts/gen-icons.sh | 41 +++++++ scripts/gen_content_icons.py | 120 ++++++++++++++++++ scripts/verify_icons.py | 167 ++++++++++++++++++++++++++ 13 files changed, 545 insertions(+), 2 deletions(-) create mode 100644 content-worker/src/icons.ts create mode 100644 dashboard/static/apple-touch-icon.png create mode 100644 dashboard/static/favicon.ico create mode 100644 dashboard/static/icon-192.png create mode 100644 dashboard/static/icon-512.png create mode 100644 dashboard/static/icon.svg create mode 100644 dashboard/static/site.webmanifest create mode 100755 scripts/gen-icons.sh create mode 100644 scripts/gen_content_icons.py create mode 100644 scripts/verify_icons.py diff --git a/content-worker/src/icons.ts b/content-worker/src/icons.ts new file mode 100644 index 0000000..3ea6b89 --- /dev/null +++ b/content-worker/src/icons.ts @@ -0,0 +1,157 @@ +// Brand icons served from the CONTENT origin. GENERATED — do not hand-edit. +// Regenerate with: scripts/gen-icons.sh +// +// WHY THIS EXISTS. Published documents are served BYTE-FOR-BYTE (that guarantee is the +// product), so we cannot inject a <link rel="icon"> into a user's HTML. But a browser +// requests /favicon.ico from the ORIGIN ROOT on its own when a document declares no +// icon, which every uploaded document does. Serving it here gives every published draft +// a real tab icon without altering a single byte of user content. +// +// The ICO is embedded as base64 rather than fetched from R2 or an assets binding: this +// worker's bindings are deliberately limited to read-only D1 + R2 for user content, and +// widening the untrusted-content origin's surface to serve 5 KB is a bad trade. Embedded +// also means no I/O and no failure mode on the hot path. + +// 16x16 + 32x32 multi-resolution ICO, derived from dashboard/static/favicon.svg. +const FAVICON_ICO_B64 = + "AAABAAIAEBAAAAEAIABoBAAAJgAAACAgAAABACAAqBAAAI4EAAAoAAAAEAAAACAAAAABACAAAAAAAAAEAAAAAAAAAAAAAAAA" + + "AAAAAAAAAAAAAAoKCksKCgrJCgoK+QoKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK+QoKCskKCgpKAAAAAAoK" + + "CksKCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCkoKCgrLCgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgrJCgoK+QoKCv8KCgr/IPXW/yD1" + + "1v8g9db/IPXW/yD11v8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK+AoKCv8KCgr/CgoK/yD11v8g9db/IPXW/yD1" + + "1v8g9db/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK////////////////////////////////////////////////////" + + "//8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv//////////////////////////////////////////////////////CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv//////////////////////////////////////////////////////CgoK/woKCv8KCgr/CgoK+QoKCv8KCgr/////////" + + "/////////////////////////////////////////////woKCv8KCgr/CgoK+AoKCssKCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCskKCgpMCgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgpQAAAAAAoKCkwKCgrKCgoK+QoKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK+QoKCsoKCgpLAAAAAIABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + + "AAAAAAAAAAAAAIABAAAoAAAAIAAAAEAAAAABACAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAwMFQoK" + + "CoIKCgrNCgoK8woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCvMKCgrNCgoKgQ0NDRQAAAAAAAAAAAAAAAAAAAAAAAAAAAkJCVQKCgrxCgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK8AkJCVIAAAAAAAAAAAAAAAAJCQlUCgoK/goKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/gkJ" + + "CVIAAAAADAwMFgkJCfIKCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK8A0NDRQKCgqDCgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoKgQoKCs8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgrOCQkJ8goKCv8KCgr/CgoK/woKCv8KCgr/IPXW/yD11v8g9db/IPXW/yD1" + + "1v8g9db/IPXW/yD11v8g9db/IPXW/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCvEKCgr/CgoK/woKCv8KCgr/CgoK/woKCv8g9db/IPXW/yD11v8g9db/IPXW/yD11v8g9db/IPXW/yD1" + + "1v8g9db/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/yD11v8g9db/IPXW/yD11v8g9db/IPXW/yD11v8g9db/IPXW/yD11v8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/IPXW/yD11v8g9db/IPXW/yD11v8g9db/IPXW/yD11v8g9db/IPXW/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK////" + + "////////////////////////////////////////////////////////////////////////////////////////////////" + + "////////CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/////////////////////////" + + "//////////////////////////////////////////////////////////////////////////////////8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv//////////////////////////////////////////////" + + "/////////////////////////////////////////////////////////////woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK////////////////////////////////////////////////////////////////////" + + "////////////////////////////////////////CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv//////////////////////////////////////////////////////////////////////////////" + + "/////////////////////////////woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK////" + + "////////////////////////////////////////////////////////////////////////////////////////////////" + + "////////CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/////////////////////////" + + "//////////////////////////////////////////////////////////////////////////////////8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgrzCgoK/woKCv8KCgr/CgoK/woKCv//////////////////////////////////////////////" + + "/////////////////////////////////////////////////////////////woKCv8KCgr/CgoK/woKCv8KCgr/CQkJ8goK" + + "CtAKCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgrOCgoKhAoKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCoIMDAwWCQkJ8goKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgrxDAwMFQAAAAAJCQlVCgoK/goKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/gkJ" + + "CVQAAAAAAAAAAAAAAAAJCQlVCQkJ8goKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/wkJCfIJCQlUAAAAAAAAAAAAAAAAAAAAAAAA" + + "AAAMDAwWCgoKhAoKCs8KCgr0CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woKCv8KCgr/CgoK/woK" + + "Cv8KCgr/CgoK/woKCv8KCgr/CgoK9AoKCs4KCgqDDAwMFgAAAAAAAAAAAAAAAOAAAAfAAAADgAAAAQAAAAAAAAAAAAAAAAAA" + + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + + "AAAAAAAAAAAAAAAAAAAAAAAAgAAAAcAAAAPgAAAH";; + +// Scalable variant for browsers that prefer an SVG icon. +const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="agentdraft"> + <!-- Geometry is deliberately pixel-aligned for a 16px favicon: the viewBox is 32 + units, so every 2 units = 1 device pixel at 16px. Bar heights (4), gaps (4), and + x-offsets (6) are all even, which keeps edges crisp instead of antialiased to mush + at the size browsers actually render in a tab. --> + <rect width="32" height="32" rx="7" fill="#0a0a0a"/> + <g fill="#ffffff"> + <rect x="6" y="6" width="20" height="4"/> + <rect x="6" y="14" width="20" height="4"/> + </g> + <!-- Half-width accent bar: the shortness is intentional (a draft is an unfinished + document), so it must read as deliberate rather than as a clipped full bar. --> + <rect x="6" y="22" width="10" height="4" fill="#d6f520"/> +</svg>`; + +let icoBytes: Uint8Array | null = null; + +function decodeIco(): Uint8Array { + if (icoBytes) return icoBytes; + const bin = atob(FAVICON_ICO_B64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + icoBytes = out; + return out; +} + +// Icons are static, public, and identical for every visitor, so they take a long +// immutable cache. They must NOT inherit securityHeaders() — that sets +// Content-Type: text/html, which would break the image and trip nosniff. +const ICON_CACHE = "public, max-age=31536000, immutable"; + +/** + * Returns an icon response for the well-known icon paths, or null if `pathname` is not + * an icon route (so the caller falls through to normal document routing). + */ +export function iconResponse(pathname: string, method: string): Response | null { + if (pathname === "/favicon.ico") { + const bytes = decodeIco(); + return new Response(method === "HEAD" ? null : bytes, { + status: 200, + headers: { + "Content-Type": "image/x-icon", + "Content-Length": String(bytes.byteLength), + "Cache-Control": ICON_CACHE, + "X-Content-Type-Options": "nosniff", + }, + }); + } + // Only /favicon.svg is served here. NOT /icon.svg: that path serves the inset + // maskable app icon on the dashboard origin, and answering it with the tab favicon + // would mean one path returns two different assets depending on origin. + if (pathname === "/favicon.svg") { + return new Response(method === "HEAD" ? null : FAVICON_SVG, { + status: 200, + headers: { + "Content-Type": "image/svg+xml; charset=utf-8", + "Cache-Control": ICON_CACHE, + "X-Content-Type-Options": "nosniff", + }, + }); + } + return null; +} diff --git a/content-worker/src/index.ts b/content-worker/src/index.ts index ebea296..6675502 100644 --- a/content-worker/src/index.ts +++ b/content-worker/src/index.ts @@ -7,6 +7,8 @@ // Bindings come from `wrangler types` (worker-configuration.d.ts, generated from // wrangler.jsonc) rather than being hand-written, so a config change that drops or // renames a binding becomes a compile error instead of a runtime crash. +import { iconResponse } from "./icons.js"; + type Env = globalThis.Env; interface DraftRow { @@ -108,6 +110,14 @@ export default { if (url.pathname === "/health") { return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" } }); } + + // Brand icons. A browser asks the ORIGIN ROOT for /favicon.ico whenever a document + // declares no icon of its own, which every uploaded document does — we serve pages + // byte-for-byte and must not inject a <link> into user HTML. Answering here gives + // every published draft a real tab icon while leaving content untouched. + const icon = iconResponse(url.pathname, request.method); + if (icon) return icon; + if (!m) return textResponse(404, "Not Found"); const draftId = m[1]; diff --git a/dashboard/src/app.html b/dashboard/src/app.html index 4ff1adb..d1e1ba0 100644 --- a/dashboard/src/app.html +++ b/dashboard/src/app.html @@ -2,7 +2,15 @@ <html lang="en"> <head> <meta charset="utf-8" /> - <link rel="icon" href="%sveltekit.assets%/favicon.svg" /> + <!-- Icons. Order matters: a browser picks the LAST rel="icon" it understands, so the + SVG comes after the .ico and modern browsers get the scalable one while older + ones (and Windows shortcuts / RSS readers) fall back to the multi-res .ico. --> + <link rel="icon" href="%sveltekit.assets%/favicon.ico" sizes="32x32" /> + <link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" /> + <link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" /> + <link rel="manifest" href="%sveltekit.assets%/site.webmanifest" /> + <!-- Matches --bg so mobile browser chrome doesn't flash white against the dark UI. --> + <meta name="theme-color" content="#0a0a0a" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> %sveltekit.head% </head> diff --git a/dashboard/static/apple-touch-icon.png b/dashboard/static/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e430bb3f8168ed20f26ce33bcf94cb568424f6e4 GIT binary patch literal 644 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD2}o{QKQR?Zu_bxCyD<C*!3BGlPiA0Xvh;Lu z45^s&_Rhko!GR)e7p--U?a9b!2{dsOUpQH0Vv`%MuS=7IYoY;1n3()GxsBzl`+tP3 zv#j(#Ua0e9diid4gXtC?j}$taRrn^+i1DTFdxQPE@4w%1HqY@}epmR~r_%@PJ4mt6 zcc<O{7`@%<A6~q)=({T|acZ-Z5WA<0O5ubfj@TJXG>_X|e_i^$#qr$p&+j<1&su)_ zD0^xDPd?4dBzQG=-TD0Ox8HHv%qprq=P*x{Pzd>2e*5|5XXW>nt=E2i`;`r}eVNSf zJ%4}fs6G^xy|?DazUo5?giS$l;H4@1)oT8JD*nzQ=le68iMcY2I<a*rpF>2Pp=|!f RWMHyl@O1TaS?83{1OQge1rq=O literal 0 HcmV?d00001 diff --git a/dashboard/static/favicon.ico b/dashboard/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7d45953a4f25c3f22ae4d710621767d706e88a60 GIT binary patch literal 15086 zcmeHO&rcIU6n+GgmI9H3cy3gTC%}tWJt=}3W8m)5Kcf`<H#{2+ME`@{j2h9CXAWw- zQT&l;La5)@?vSO-b{@Oa-7U@}uhX4*^S*E1yq)dLY(>UoTxMnjd0n2I6S*NG^?L96 zIm(}*Y;iHVo)ft}D{>7ws6vZgIWiIG*;1`mTY<cV{DpKJLLKTZ(-v~Jb+t*VUq;*a z8QLG?T>t&K(=QI&f8zE1ZFwE9FZ=h7G9~px*}SwqNy+~x=2Nt1{A7*adr9M=D1R8; zgS7Dua@3arZK*@u6}D}R@iSdwTh23e$Snxxr*7iJGayY(P0a$o3Hbo|3Go$Ev_+fi z)6>%zY!oIYCRS0u?-4VJdmC7f;v6FE!yK>;r$fU5KEH_XKXSx29NHWU;BFLvZ8)q0 z`(uIlJ4wE`J#%bj8M!CMyA8`$mJ#2K!-?F6Wh*<Y_`~W~u<Pxaaw2AZ`d0g!L#uaM zZd$uqy?{R&ad+Br3i`ttSM7bJ)jRDtY3*wDN{nw^XK4GS)wA_K`tA40R+m%R4sbSe z!m>Rtn+2{v_PO5VIt=5m1|Q_T{#xx@1Fj{l1$>SVF(AjV4G)e*6c1bz{ql%QTa2|0 zVi-K;yCRjA$asU>1UV&QwSc(`Z0=KFi&P#%j`k_8;6j-uCnp!!FUT9nSI7ax7VE<( zBNj1<-2|6;C2grxDwok_3zBEtyL><{FRRt+r9u5bKkq<q58|>{NPTo4-?#MvoR=Ya z#z<Np9Kg>@@jf7C=D@uTNozCjc6;$y0`Kh+;@<TY#^43V0QusmagX>Og8Mwa7rD=u zHSQYV&oia0c8Y4x=fgJ_f7||=w#;QaWBnJjcg~qwt~R}l{9NPe<W77WFKyY7`Mdhp zt=Dq3=>`7T=N6k^wPj`Sf8dZk27(WR4?_&3ivjIko^L&0<K>%|)?VP>i#M&meCq}O zpAG-}8pmKK-TWW?7X0=<$AItWlGdItpKI}a>jnNJ@%(k}!TF90tvyBjko$L+o)*uy zo{Jaic}6+v`{z#;{Z#Ff7GQ_()L$<E*M~oNezd?dFz|mpf_>N`cP5;lUIqWecdkp= zw>IAs=&|p8f+PRe<(x}P&=+#N8|QAmBc7H}SK4#z=>35QhOaG;V(C>0{M>(!Z}U9? z8)sWtbgs?0mCpyBQRW$2#3ClKH_bd_<9Wcsn<8!I=ezGM;f5Az@p>Hpmtu}Lm`u*g Ykdi&nU{%atEndTqyUgKN<7eade;Zg%_W%F@ literal 0 HcmV?d00001 diff --git a/dashboard/static/favicon.svg b/dashboard/static/favicon.svg index ad5bc3c..bd3150e 100644 --- a/dashboard/static/favicon.svg +++ b/dashboard/static/favicon.svg @@ -1 +1,14 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#6ea8fe"/><path d="M8 11l3 10 3-7 3 7 3-10" fill="none" stroke="#0b1220" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="agentdraft"> + <!-- Geometry is deliberately pixel-aligned for a 16px favicon: the viewBox is 32 + units, so every 2 units = 1 device pixel at 16px. Bar heights (4), gaps (4), and + x-offsets (6) are all even, which keeps edges crisp instead of antialiased to mush + at the size browsers actually render in a tab. --> + <rect width="32" height="32" rx="7" fill="#0a0a0a"/> + <g fill="#ffffff"> + <rect x="6" y="6" width="20" height="4"/> + <rect x="6" y="14" width="20" height="4"/> + </g> + <!-- Half-width accent bar: the shortness is intentional (a draft is an unfinished + document), so it must read as deliberate rather than as a clipped full bar. --> + <rect x="6" y="22" width="10" height="4" fill="#d6f520"/> +</svg> diff --git a/dashboard/static/icon-192.png b/dashboard/static/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..c99a500f692c0e263d384875b3f8724bb23bf397 GIT binary patch literal 615 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d5Vcu_bxCyD<C*!3BGlPiA0XlJRtL z45^s&_Rd1VLjfYLj)xZ1nS?m~7Y*QiQDNfI_k(kD^g+?|c{Qgc9<%JXIxlx%BWH(; zg9?+9pg?DX2aAi6f)L|G4w?xS&9r@Rdt2^i^*6V6t=p_#Q_x73osq{RHk%hD=n?B? z|F7(igKg_w$?@p?SDrCM2Fj$nwX5p)<N^J)BYr;6kF&VR3s3*ADnxsY_(=Q2wxXBo Vx6|L1|A8ro!PC{xWt~$(69D#OrUL)~ literal 0 HcmV?d00001 diff --git a/dashboard/static/icon-512.png b/dashboard/static/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..4e311ce13026089460364ecab54054234be8145a GIT binary patch literal 1956 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&t&wwUqN(AjOvC?e4<x9|RZdT|SwCfj!dG z#WAE}&fDu716dpeSRDH*GQ*zf9<1ly!1Cm(VRFl<Eep1G)ulaSZkTueEhEDl0fq(* zMurZdQEG?<K~*mQirwGy{2NT4&w0z4DaYV2g@M6^b2w60jE!$gGekUK90LB^yN&(I z-RkY?2WAzY`_^K@&!FJNz_5sAI8v`>&V0+upz~oU_%H4@^Q*dl_ihVc+PA+B7&}s< h5i>j!C9QJXKSpnF-s)skF;KP3;OXk;vd$@?2>?#TX<+~W literal 0 HcmV?d00001 diff --git a/dashboard/static/icon.svg b/dashboard/static/icon.svg new file mode 100644 index 0000000..9e48b49 --- /dev/null +++ b/dashboard/static/icon.svg @@ -0,0 +1,11 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="agentdraft"> + <!-- Maskable/app icon: same mark as favicon.svg but with the artwork pulled inside a + safe zone, because Android and iOS crop app icons to a circle or squircle. The + favicon keeps tighter margins since a browser tab never crops. --> + <rect width="32" height="32" fill="#0a0a0a"/> + <g fill="#ffffff"> + <rect x="8" y="8" width="16" height="4"/> + <rect x="8" y="15" width="16" height="4"/> + </g> + <rect x="8" y="22" width="8" height="4" fill="#d6f520"/> +</svg> diff --git a/dashboard/static/site.webmanifest b/dashboard/static/site.webmanifest new file mode 100644 index 0000000..d7cb371 --- /dev/null +++ b/dashboard/static/site.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "agentdraft", + "short_name": "agentdraft", + "description": "Publish AI-agent plans to stable, versioned review URLs.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#0a0a0a", + "theme_color": "#0a0a0a", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/scripts/gen-icons.sh b/scripts/gen-icons.sh new file mode 100755 index 0000000..045e093 --- /dev/null +++ b/scripts/gen-icons.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Regenerate every brand icon from the two SVG sources of truth. +# +# dashboard/static/favicon.svg -> tab icon; geometry is pixel-aligned for 16px +# dashboard/static/icon.svg -> app/maskable icon; artwork inset for OS cropping +# +# Everything else in dashboard/static/ and content-worker/src/icons.ts is DERIVED. +# Never hand-edit a derived file: run this instead, then commit the result. +# +# Requires: rsvg-convert (librsvg), ImageMagick `convert`, python3 (+Pillow for verify). +set -euo pipefail + +cd "$(dirname "$0")/.." +STATIC="dashboard/static" +BG="#0a0a0a" # must match --bg in dashboard/src/lib/styles.css + +command -v rsvg-convert >/dev/null || { echo "need rsvg-convert (apt install librsvg2-bin)"; exit 1; } +command -v convert >/dev/null || { echo "need ImageMagick convert"; exit 1; } + +echo "==> PNG app icons (opaque: iOS composites a transparent icon onto WHITE)" +rsvg-convert -w 180 -h 180 -b "$BG" "$STATIC/icon.svg" -o "$STATIC/apple-touch-icon.png" +rsvg-convert -w 192 -h 192 -b "$BG" "$STATIC/icon.svg" -o "$STATIC/icon-192.png" +rsvg-convert -w 512 -h 512 -b "$BG" "$STATIC/icon.svg" -o "$STATIC/icon-512.png" + +echo "==> multi-resolution favicon.ico (16/32/48)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +for s in 16 32 48; do rsvg-convert -w $s -h $s "$STATIC/favicon.svg" -o "$tmp/i-$s.png"; done +convert "$tmp/i-16.png" "$tmp/i-32.png" "$tmp/i-48.png" "$STATIC/favicon.ico" + +echo "==> content-worker embedded icons (16/32 only — keeps the worker bundle small)" +for s in 16 32; do rsvg-convert -w $s -h $s "$STATIC/favicon.svg" -o "$tmp/c-$s.png"; done +convert "$tmp/c-16.png" "$tmp/c-32.png" "$tmp/content.ico" +python3 scripts/gen_content_icons.py "$tmp/content.ico" "$STATIC/favicon.svg" \ + content-worker/src/icons.ts + +chmod 644 "$STATIC"/favicon.svg "$STATIC"/icon.svg "$STATIC"/*.png "$STATIC"/favicon.ico + +echo "==> verify" +python3 scripts/verify_icons.py +echo "OK" diff --git a/scripts/gen_content_icons.py b/scripts/gen_content_icons.py new file mode 100644 index 0000000..e02bdc8 --- /dev/null +++ b/scripts/gen_content_icons.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Generate content-worker/src/icons.ts from an .ico and an .svg. + +Called by scripts/gen-icons.sh. Reads the ICO as BINARY and base64-encodes it here, so +the embedded payload is always byte-exact — an earlier hand-rolled attempt corrupted the +base64 by passing it through a line-numbered text read, producing a module that compiled +fine and served a broken image. The generator asserts round-trip equality to make that +class of failure impossible to commit. +""" +import base64 +import pathlib +import sys + +TEMPLATE = '''// Brand icons served from the CONTENT origin. GENERATED — do not hand-edit. +// Regenerate with: scripts/gen-icons.sh +// +// WHY THIS EXISTS. Published documents are served BYTE-FOR-BYTE (that guarantee is the +// product), so we cannot inject a <link rel="icon"> into a user's HTML. But a browser +// requests /favicon.ico from the ORIGIN ROOT on its own when a document declares no +// icon, which every uploaded document does. Serving it here gives every published draft +// a real tab icon without altering a single byte of user content. +// +// The ICO is embedded as base64 rather than fetched from R2 or an assets binding: this +// worker's bindings are deliberately limited to read-only D1 + R2 for user content, and +// widening the untrusted-content origin's surface to serve 5 KB is a bad trade. Embedded +// also means no I/O and no failure mode on the hot path. + +// 16x16 + 32x32 multi-resolution ICO, derived from dashboard/static/favicon.svg. +const FAVICON_ICO_B64 = +{ico_literal}; + +// Scalable variant for browsers that prefer an SVG icon. +const FAVICON_SVG = {svg_literal}; + +let icoBytes: Uint8Array | null = null; + +function decodeIco(): Uint8Array {{ + if (icoBytes) return icoBytes; + const bin = atob(FAVICON_ICO_B64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + icoBytes = out; + return out; +}} + +// Icons are static, public, and identical for every visitor, so they take a long +// immutable cache. They must NOT inherit securityHeaders() — that sets +// Content-Type: text/html, which would break the image and trip nosniff. +const ICON_CACHE = "public, max-age=31536000, immutable"; + +/** + * Returns an icon response for the well-known icon paths, or null if `pathname` is not + * an icon route (so the caller falls through to normal document routing). + */ +export function iconResponse(pathname: string, method: string): Response | null {{ + if (pathname === "/favicon.ico") {{ + const bytes = decodeIco(); + return new Response(method === "HEAD" ? null : bytes, {{ + status: 200, + headers: {{ + "Content-Type": "image/x-icon", + "Content-Length": String(bytes.byteLength), + "Cache-Control": ICON_CACHE, + "X-Content-Type-Options": "nosniff", + }}, + }}); + }} + // Only /favicon.svg is served here. NOT /icon.svg: that path serves the inset + // maskable app icon on the dashboard origin, and answering it with the tab favicon + // would mean one path returns two different assets depending on origin. + if (pathname === "/favicon.svg") {{ + return new Response(method === "HEAD" ? null : FAVICON_SVG, {{ + status: 200, + headers: {{ + "Content-Type": "image/svg+xml; charset=utf-8", + "Cache-Control": ICON_CACHE, + "X-Content-Type-Options": "nosniff", + }}, + }}); + }} + return null; +}} +''' + + +def chunk_literal(b64: str, width: int = 96) -> str: + """Emit the base64 as concatenated string chunks so the file stays readable.""" + parts = [b64[i:i + width] for i in range(0, len(b64), width)] + return "\n".join(f' "{p}"' + (" +" if i < len(parts) - 1 else ";") + for i, p in enumerate(parts)) + + +def main() -> int: + ico_path, svg_path, out_path = (pathlib.Path(p) for p in sys.argv[1:4]) + + raw = ico_path.read_bytes() + if raw[:4] != b"\x00\x00\x01\x00": + print(f"ERROR: {ico_path} is not an ICO (bad magic {raw[:4]!r})", file=sys.stderr) + return 1 + b64 = base64.b64encode(raw).decode("ascii") + + # Round-trip assertion: the whole reason this generator exists. + assert base64.b64decode(b64, validate=True) == raw, "base64 round-trip mismatch" + + svg = svg_path.read_text(encoding="utf-8").strip() + if "`" in svg or "${" in svg: + print("ERROR: SVG contains a backtick or ${ — unsafe in a TS template literal", + file=sys.stderr) + return 1 + + out_path.write_text( + TEMPLATE.format(ico_literal=chunk_literal(b64), svg_literal=f"`{svg}`"), + encoding="utf-8", + ) + print(f" wrote {out_path} (ico {len(raw)} B -> {len(b64)} b64 chars, svg {len(svg)} B)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/verify_icons.py b/scripts/verify_icons.py new file mode 100644 index 0000000..380a101 --- /dev/null +++ b/scripts/verify_icons.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Verify every icon asset is well-formed, on-brand, and correctly embedded. + +Run by scripts/gen-icons.sh, and safe to run standalone in CI. Checks the properties that +actually break in the wild rather than merely asserting files exist: + + * favicon.ico really is multi-resolution (a single-size .ico looks fine until Windows + or a bookmarks bar asks for 48px and gets a blurry upscale) + * PNG app icons are fully OPAQUE (iOS composites a transparent apple-touch-icon onto + WHITE, which would show a white box around a dark icon) + * the base64 embedded in content-worker/src/icons.ts decodes to the SAME bytes as + dashboard/static/favicon.ico's 16/32 pair — the generator's whole reason to exist + * favicon.svg geometry lands on whole device pixels at 16px, with zero antialiased + rows, because a favicon that is 1px off-grid renders as mush in a tab + * the manifest's icons all exist on disk and its theme colour matches the CSS --bg +""" +import base64 +import json +import pathlib +import re +import subprocess +import sys +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parent.parent +STATIC = ROOT / "dashboard" / "static" +BG = "#0a0a0a" +ACCENT = "#d6f520" + +failures: list[str] = [] +checks = 0 + + +def ok(cond: bool, label: str, detail: str = "") -> None: + global checks + checks += 1 + if cond: + print(f" PASS {label}" + (f" ({detail})" if detail else "")) + else: + print(f" FAIL {label}" + (f" ({detail})" if detail else "")) + failures.append(label) + + +def main() -> int: + from PIL import Image + + print("== files present ==") + expected = [ + "favicon.svg", "icon.svg", "favicon.ico", "apple-touch-icon.png", + "icon-192.png", "icon-512.png", "site.webmanifest", + ] + for name in expected: + ok((STATIC / name).is_file(), f"{name} exists") + + print("== favicon.ico is multi-resolution ==") + ico = Image.open(STATIC / "favicon.ico") + sizes = sorted(ico.info.get("sizes", [])) + ok(len(sizes) >= 3, "ico has >=3 sizes", str(sizes)) + ok((16, 16) in sizes and (32, 32) in sizes, "ico covers 16 and 32") + + print("== PNG app icons are opaque and correctly sized ==") + for name, want in (("apple-touch-icon.png", 180), ("icon-192.png", 192), + ("icon-512.png", 512)): + im = Image.open(STATIC / name) + lo = im.convert("RGBA").getchannel("A").getextrema()[0] + ok(im.size == (want, want), f"{name} is {want}x{want}", str(im.size)) + ok(lo == 255, f"{name} fully opaque", f"min alpha={lo}") + + print("== content-worker embed matches the source bytes ==") + icons_ts = (ROOT / "content-worker" / "src" / "icons.ts").read_text() + literals = re.findall(r'"([A-Za-z0-9+/=]+)"', icons_ts) + b64 = "".join(l for l in literals if len(l) > 32) + ok(bool(b64), "found embedded base64", f"{len(b64)} chars") + try: + raw = base64.b64decode(b64, validate=True) + decoded = True + except Exception as exc: # noqa: BLE001 + raw, decoded = b"", False + ok(False, "embedded base64 decodes", str(exc)) + if decoded: + ok(True, "embedded base64 decodes", f"{len(raw)} bytes") + ok(raw[:4] == b"\x00\x00\x01\x00", "embedded payload is a real ICO", + repr(raw[:4])) + with tempfile.NamedTemporaryFile(suffix=".ico", delete=False) as fh: + fh.write(raw) + tmp = fh.name + emb = sorted(Image.open(tmp).info.get("sizes", [])) + ok((16, 16) in emb and (32, 32) in emb, "embedded ico covers 16 and 32", str(emb)) + # The embedded 16px frame must be pixel-identical to a fresh render of the SVG. + with tempfile.TemporaryDirectory() as td: + ref = pathlib.Path(td) / "ref.png" + subprocess.run(["rsvg-convert", "-w", "16", "-h", "16", + str(STATIC / "favicon.svg"), "-o", str(ref)], check=True) + a = Image.open(ref).convert("RGBA") + # Pillow exposes ICO frames via size selection on load; ask for the 16px one + # explicitly rather than mutating .size (which is a read-only property). + b = Image.open(tmp) + frame16 = b.ico.getimage((16, 16)).convert("RGBA") # type: ignore[attr-defined] + same = bytes(a.tobytes()) == bytes(frame16.tobytes()) + ok(same, "embedded 16px frame matches a fresh SVG render") + + print("== favicon.svg is pixel-aligned at 16px ==") + with tempfile.TemporaryDirectory() as td: + png = pathlib.Path(td) / "f16.png" + subprocess.run(["rsvg-convert", "-w", "16", "-h", "16", + str(STATIC / "favicon.svg"), "-o", str(png)], check=True) + im = Image.open(png).convert("RGBA") + px = im.load() + + def kind(p): + r, g, b_, a = p + if a < 40: + return "." + if r > 200 and g > 200 and b_ > 200: + return "W" + if r > 150 and g > 170 and b_ < 120: + return "A" + if r < 60 and g < 60 and b_ < 60: + return "k" + return "?" + + rows = ["".join(kind(px[x, y]) for x in range(16)) for y in range(16)] + # Ignore the 4 corner pixels: a rounded rect MUST antialias its corners. + interior = "".join(r[1:-1] for r in rows[1:-1]) + ok("?" not in interior, "no antialiased pixels in the interior", + f"{interior.count('?')} found") + + wruns = [(y, r.count("W")) for y, r in enumerate(rows) if "W" in r] + aruns = [(y, r.count("A")) for y, r in enumerate(rows) if "A" in r] + ok(len({n for _, n in wruns}) == 1, "white bars identical length", + str(sorted({n for _, n in wruns}))) + ok(bool(aruns), "accent bar survives at 16px", + f"{sum(n for _, n in aruns)} px") + if wruns and aruns: + ratio = aruns[0][1] / wruns[0][1] + ok(abs(ratio - 0.5) < 0.12, "accent bar is ~half width", + f"ratio={ratio:.2f}") + bars = [y for y, _ in wruns + aruns] + top, bot = min(bars), 15 - max(bars) + ok(abs(top - bot) <= 1, "stack vertically balanced", + f"top={top} bottom={bot}") + + print("== manifest ==") + mf = json.loads((STATIC / "site.webmanifest").read_text()) + ok(mf.get("theme_color") == BG, "theme_color matches --bg", mf.get("theme_color")) + ok(mf.get("background_color") == BG, "background_color matches --bg") + for icon in mf.get("icons", []): + src = icon["src"].lstrip("/") + ok((STATIC / src).is_file(), f"manifest icon {icon['src']} exists") + ok(any(i.get("purpose") == "maskable" for i in mf.get("icons", [])), + "a maskable icon is declared") + + print("== brand colours ==") + for name in ("favicon.svg", "icon.svg"): + txt = (STATIC / name).read_text() + ok(BG in txt, f"{name} uses --bg {BG}") + ok(ACCENT in txt, f"{name} uses accent {ACCENT}") + + print(f"\n{checks - len(failures)}/{checks} checks passed") + if failures: + print("FAILED: " + ", ".join(failures)) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 66951616c5139dabf9ec354c764e1e0ce35efbb1 Mon Sep 17 00:00:00 2001 From: Hitesh Sisara <hitesh.dev@sisara.in> Date: Sat, 8 Aug 2026 09:05:46 +0000 Subject: [PATCH 4/9] feat(brand): logo in nav and README, and fix the broken preview iframe Adds the favicon mark as a reusable inline-SVG Logo component in the nav lockup, plus a README/docs variant, so the mark is one thing in three places instead of three drifting copies. FIXES THE EMBEDDED VIEWER (found while verifying this). The content worker sent `frame-ancestors 'self'` with a comment claiming it allowed the dashboard preview iframe. It does not: 'self' is the CONTENT origin, while the iframe is embedded from the DASHBOARD origin, so every browser refused the frame and the preview rendered as Chrome's blank subframe error page. The comment asserted the opposite of the behaviour, which is how it survived review. frame-ancestors is now built per request from a new DASHBOARD_ORIGIN var, naming the embedding origin explicitly, and falls back to 'none' rather than 'self' when unset -- an unconfigured untrusted-content origin should refuse all framing, not guess. Proven with a differential in a real browser: /health and /d/:id on the SAME origin and worker, only the latter carrying frame-ancestors. Both fetched fine, /health committed and rendered its JSON, /d/:id stayed on the error glyph. That isolates the CSP header as the cause rather than a network or 5xx failure, which a blank frame alone cannot distinguish. Two defects in my own first cut, both caught by inspecting the rendered result rather than the source: * The nav link announced "agentdraft agentdraft" to a screen reader -- the SVG's aria-label duplicated the adjacent wordmark. The mark is now decorative by default and takes a label only when it stands alone. * The badge was invisible: a #0a0a0a tile on a #0a0a0a nav with a #2a2a2a edge, and a 1-unit stroke in a 32-unit viewBox renders at ~0.7px. Tile now sits a step above the background with a 1.5-unit light stroke, confirmed via computed style, not by eye. The nav CSS selector was also narrowed to .brand__word: a bare `.nav .brand span` would match the new wordmark wrapper and paint the whole word accent instead of just "draft". README logo keeps transparent corners so it reads on GitHub in both light and dark themes, and verify_icons.py now asserts that plus the accent colour and the README reference, so a rename cannot silently break the render. 41/41 icon checks pass. --- README.md | 2 + content-worker/src/index.ts | 46 +- content-worker/worker-configuration.d.ts | 626 ++++++++++++++++------- content-worker/wrangler.jsonc | 8 + dashboard/src/lib/Logo.svelte | 60 +++ dashboard/src/lib/styles.css | 10 +- dashboard/src/routes/+layout.svelte | 6 +- dashboard/static/logo-readme.svg | 16 + docs/assets/logo.png | Bin 0 -> 2674 bytes scripts/gen-icons.sh | 4 + scripts/verify_icons.py | 22 +- 11 files changed, 596 insertions(+), 204 deletions(-) create mode 100644 dashboard/src/lib/Logo.svelte create mode 100644 dashboard/static/logo-readme.svg create mode 100644 docs/assets/logo.png diff --git a/README.md b/README.md index 47ffc60..369cc73 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ <div align="center"> +<img src="./docs/assets/logo.png" alt="agentdraft" width="88" height="88"> + # agentdraft **Publish AI-agent plans to stable, versioned review URLs — HTML and Markdown, one command.** diff --git a/content-worker/src/index.ts b/content-worker/src/index.ts index 6675502..3ef86f7 100644 --- a/content-worker/src/index.ts +++ b/content-worker/src/index.ts @@ -39,23 +39,39 @@ function renderedKeyFor(sourceKey: string): string { // Defense-in-depth CSP. script-src 'none' means even if validation somehow let a // script through, the browser won't execute it. style-src 'unsafe-inline' is required // because the product's whole point is self-contained styled documents. -const CSP = [ - "default-src 'none'", - "img-src https: data:", - "style-src 'unsafe-inline'", - "font-src https: data:", - "media-src https:", - "script-src 'none'", - "connect-src 'none'", - "form-action 'none'", - "frame-ancestors 'self'", // allow dashboard preview iframe on the api origin - "base-uri 'none'", -].join("; "); +// +// frame-ancestors is built PER REQUEST because it must name the dashboard origin +// explicitly. It previously read `frame-ancestors 'self'` with a comment claiming that +// allowed the dashboard preview iframe — it does not. 'self' is THIS origin (the content +// origin); the dashboard is a DIFFERENT origin, so the browser refused the frame and the +// preview rendered as Chrome's blank subframe error page. The comment asserted the +// opposite of the behaviour, which is why it survived review. +function buildCSP(dashboardOrigin: string | undefined): string { + // Fall back to 'none' rather than 'self' when unset: if the embedding origin is not + // configured, refusing all framing is the safe default. 'self' would be a lie either + // way, and a permissive fallback on an untrusted-content origin is the wrong risk. + const frameAncestors = dashboardOrigin ? `frame-ancestors ${dashboardOrigin}` : "frame-ancestors 'none'"; + return [ + "default-src 'none'", + "img-src https: data:", + "style-src 'unsafe-inline'", + "font-src https: data:", + "media-src https:", + "script-src 'none'", + "connect-src 'none'", + "form-action 'none'", + frameAncestors, + "base-uri 'none'", + ].join("; "); +} -function securityHeaders(extra: Record<string, string> = {}): Record<string, string> { +function securityHeaders( + dashboardOrigin: string | undefined, + extra: Record<string, string> = {}, +): Record<string, string> { return { "Content-Type": "text/html; charset=utf-8", - "Content-Security-Policy": CSP, + "Content-Security-Policy": buildCSP(dashboardOrigin), "X-Content-Type-Options": "nosniff", "X-Robots-Tag": "noindex, nofollow", "Referrer-Policy": "no-referrer", @@ -223,7 +239,7 @@ export default { // vary the tag by representation to keep conditional requests correct. const repEtag = serveRendered ? `"${version.content_hash}-r"` : etag; - const headers = securityHeaders({ + const headers = securityHeaders(env.DASHBOARD_ORIGIN, { "Content-Type": contentType, "Cache-Control": cacheControl, ETag: repEtag, diff --git a/content-worker/worker-configuration.d.ts b/content-worker/worker-configuration.d.ts index 165449a..fac058d 100644 --- a/content-worker/worker-configuration.d.ts +++ b/content-worker/worker-configuration.d.ts @@ -1,9 +1,10 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 87ec9f1e1802e2b5cd0d3708dc64da2e) -// Runtime types generated with workerd@1.20260601.1 2026-08-01 +// Generated by Wrangler by running `wrangler types` (hash: e2974fee7e3c167d44f4a4bdda8ec7b8) +// Runtime types generated with workerd@1.20260730.1 2026-08-01 interface __BaseEnv_Env { STORAGE: R2Bucket; DB: D1Database; + DASHBOARD_ORIGIN: "https://app.agentdraft.tapetide.com"; } declare namespace Cloudflare { interface GlobalProps { @@ -436,7 +437,8 @@ interface ExecutionContext<Props = unknown> { readonly exports: Cloudflare.Exports; readonly props: Props; cache?: CacheContext; - tracing?: Tracing; + readonly access?: CloudflareAccessContext; + tracing: Tracing; } type ExportedHandlerFetchHandler<Env = unknown, CfHostMetadata = unknown, Props = unknown> = (request: Request<CfHostMetadata, IncomingRequestCfProperties<CfHostMetadata>>, env: Env, ctx: ExecutionContext<Props>) => Response | Promise<Response>; type ExportedHandlerConnectHandler<Env = unknown, Props = unknown> = (socket: Socket, env: Env, ctx: ExecutionContext<Props>) => void | Promise<void>; @@ -492,6 +494,10 @@ interface CachePurgeOptions { interface CacheContext { purge(options: CachePurgeOptions): Promise<CachePurgeResult>; } +interface CloudflareAccessContext { + readonly aud: string; + getIdentity(): Promise<CloudflareAccessIdentity | undefined>; +} declare abstract class ColoLocalActorNamespace { get(actorId: string): Fetcher; } @@ -521,11 +527,11 @@ declare abstract class DurableObjectNamespace<T extends Rpc.DurableObjectBranded getByName(name: string, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub<T>; jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace<T>; } -type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high"; +type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high" | "us"; interface DurableObjectNamespaceNewUniqueIdOptions { jurisdiction?: DurableObjectJurisdiction; } -type DurableObjectLocationHint = "wnam" | "enam" | "sam" | "weur" | "eeur" | "apac" | "oc" | "afr" | "me"; +type DurableObjectLocationHint = "wnam" | "enam" | "sam" | "weur" | "eeur" | "apac" | "apac-ne" | "apac-se" | "oc" | "afr" | "me"; type DurableObjectRoutingMode = "primary-only"; interface DurableObjectNamespaceGetDurableObjectOptions { locationHint?: DurableObjectLocationHint; @@ -621,6 +627,7 @@ interface DurableObjectFacets { get<T extends Rpc.DurableObjectBranded | undefined = undefined>(name: string, getStartupOptions: () => FacetStartupOptions<T> | Promise<FacetStartupOptions<T>>): Fetcher<T>; abort(name: string, reason: any): void; delete(name: string): void; + clone(src: string, dst: string): void; } interface FacetStartupOptions<T extends Rpc.DurableObjectBranded | undefined = undefined> { id?: DurableObjectId | string; @@ -3296,6 +3303,28 @@ interface EventSourceEventSourceInit { withCredentials?: boolean; fetcher?: Fetcher; } +interface ExecOutput { + readonly stdout: ArrayBuffer; + readonly stderr: ArrayBuffer; + readonly exitCode: number; +} +interface ContainerExecOptions { + cwd?: string; + env?: Record<string, string>; + user?: string; + stdin?: ReadableStream | "pipe"; + stdout?: "pipe" | "ignore"; + stderr?: "pipe" | "ignore" | "combined"; +} +interface ExecProcess { + readonly stdin: WritableStream | null; + readonly stdout: ReadableStream | null; + readonly stderr: ReadableStream | null; + readonly pid: number; + readonly exitCode: Promise<number>; + output(): Promise<ExecOutput>; + kill(signal?: number): void; +} interface Container { get running(): boolean; start(options?: ContainerStartupOptions): void; @@ -3309,6 +3338,7 @@ interface Container { snapshotDirectory(options: ContainerDirectorySnapshotOptions): Promise<ContainerDirectorySnapshot>; snapshotContainer(options: ContainerSnapshotOptions): Promise<ContainerSnapshot>; interceptOutboundHttps(addr: string, binding: Fetcher): Promise<void>; + exec(cmd: string[], options?: ContainerExecOptions): Promise<ExecProcess>; } interface ContainerDirectorySnapshot { id: string; @@ -3479,11 +3509,58 @@ declare abstract class Performance { } interface Tracing { enterSpan<T, A extends unknown[]>(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T; + startActiveSpan<T, A extends unknown[]>(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T; Span: typeof Span; } declare abstract class Span { get isTraced(): boolean; setAttribute(key: string, value?: (boolean | number | string)): void; + end(): void; +} +/** + * Represents the identity of a user authenticated via Cloudflare Access. + * This matches the result of calling /cdn-cgi/access/get-identity. + * + * The exact structure of the returned object depends on the identity provider + * configuration for the Access application. The fields below represent commonly + * available properties, but additional provider-specific fields may be present. + */ +interface CloudflareAccessIdentity extends Record<string, unknown> { + /** The user's email address, if available from the identity provider. */ + email?: string; + /** The user's display name. */ + name?: string; + /** The user's unique identifier. */ + user_uuid?: string; + /** The Cloudflare account ID. */ + account_id?: string; + /** Login timestamp (Unix epoch seconds). */ + iat?: number; + /** The user's IP address at authentication time. */ + ip?: string; + /** Authentication methods used (e.g., "pwd"). */ + amr?: string[]; + /** Identity provider information. */ + idp?: { + id: string; + type: string; + }; + /** Geographic information about where the user authenticated. */ + geo?: { + country: string; + }; + /** Group memberships from the identity provider. */ + groups?: Array<{ + id: string; + name: string; + email?: string; + }>; + /** Device posture check results, keyed by check ID. */ + devicePosture?: Record<string, unknown>; + /** True if the user connected via Cloudflare WARP. */ + is_warp?: boolean; + /** True if the user is authenticated via Cloudflare Gateway. */ + is_gateway?: boolean; } // ============================================================================ // Agent Memory @@ -11076,78 +11153,6 @@ declare abstract class BrowserRun { */ quickAction(action: 'markdown', options: BrowserRunMarkdownOptions): Promise<Response>; } -interface BasicImageTransformations { - /** - * Maximum width in image pixels. The value must be an integer. - */ - width?: number; - /** - * Maximum height in image pixels. The value must be an integer. - */ - height?: number; - /** - * Resizing mode as a string. It affects interpretation of width and height - * options: - * - scale-down: Similar to contain, but the image is never enlarged. If - * the image is larger than given width or height, it will be resized. - * Otherwise its original size will be kept. - * - contain: Resizes to maximum size that fits within the given width and - * height. If only a single dimension is given (e.g. only width), the - * image will be shrunk or enlarged to exactly match that dimension. - * Aspect ratio is always preserved. - * - cover: Resizes (shrinks or enlarges) to fill the entire area of width - * and height. If the image has an aspect ratio different from the ratio - * of width and height, it will be cropped to fit. - * - crop: The image will be shrunk and cropped to fit within the area - * specified by width and height. The image will not be enlarged. For images - * smaller than the given dimensions it's the same as scale-down. For - * images larger than the given dimensions, it's the same as cover. - * See also trim. - * - pad: Resizes to the maximum size that fits within the given width and - * height, and then fills the remaining area with a background color - * (white by default). Use of this mode is not recommended, as the same - * effect can be more efficiently achieved with the contain mode and the - * CSS object-fit: contain property. - * - squeeze: Stretches and deforms to the width and height given, even if it - * breaks aspect ratio - */ - fit?: "scale-down" | "contain" | "cover" | "crop" | "pad" | "squeeze"; - /** - * Image segmentation using artificial intelligence models. Sets pixels not - * within selected segment area to transparent e.g "foreground" sets every - * background pixel as transparent. - */ - segment?: "foreground"; - /** - * When cropping with fit: "cover", this defines the side or point that should - * be left uncropped. The value is either a string - * "left", "right", "top", "bottom", "auto", or "center" (the default), - * or an object {x, y} containing focal point coordinates in the original - * image expressed as fractions ranging from 0.0 (top or left) to 1.0 - * (bottom or right), 0.5 being the center. {fit: "cover", gravity: "top"} will - * crop bottom or left and right sides as necessary, but won’t crop anything - * from the top. {fit: "cover", gravity: {x:0.5, y:0.2}} will crop each side to - * preserve as much as possible around a point at 20% of the height of the - * source image. - */ - gravity?: 'face' | 'left' | 'right' | 'top' | 'bottom' | 'center' | 'auto' | 'entropy' | BasicImageTransformationsGravityCoordinates; - /** - * Background color to add underneath the image. Applies only to images with - * transparency (such as PNG). Accepts any CSS color (#RRGGBB, rgba(…), - * hsl(…), etc.) - */ - background?: string; - /** - * Number of degrees (90, 180, 270) to rotate the image by. width and height - * options refer to axes after rotation. - */ - rotate?: 0 | 90 | 180 | 270 | 360; -} -interface BasicImageTransformationsGravityCoordinates { - x?: number; - y?: number; - mode?: 'remainder' | 'box-center'; -} /** * In addition to the properties you can set in the RequestInit dict * that you pass as an argument to the Request constructor, you can @@ -11186,6 +11191,8 @@ interface RequestInitCfProperties extends Record<string, unknown> { * (e.g. { '200-299': 86400, '404': 1, '500-599': 0 }) */ cacheTtlByStatus?: Record<string, number>; + /** Controls how responses with a `Vary` header are cached for this request. */ + vary?: RequestInitCfPropertiesVary; /** * Explicit Cache-Control header value to set on the response stored in cache. * This gives full control over cache directives (e.g. 'public, max-age=3600, s-maxage=86400'). @@ -11223,6 +11230,17 @@ interface RequestInitCfProperties extends Record<string, unknown> { cacheReserveMinimumFileSize?: number; scrapeShield?: boolean; apps?: boolean; + /** + * Controls whether an outbound gRPC-web subrequest from this Worker is + * converted to gRPC at the Cloudflare edge. + * + * - `"passthrough"`: forward the subrequest unchanged as gRPC-web (default). + * - `"convert"`: convert the gRPC-web subrequest to gRPC at the edge. + * + * Provides per-request control over the same edge conversion behavior + * gated by the `auto_grpc_convert` compatibility flag. + */ + grpcWeb?: "passthrough" | "convert"; image?: RequestInitCfPropertiesImage; minify?: RequestInitCfPropertiesImageMinify; mirage?: boolean; @@ -11243,6 +11261,244 @@ interface RequestInitCfProperties extends Record<string, unknown> { */ resolveOverride?: string; } +/** + * Controls how Workers Standard Vary handles a request header listed by an + * origin `Vary` response header: + * + * - `"normalize"`: normalize the request header value before it is used in the + * cache variance key. + * - `"passthrough"`: use the raw request header value in the cache variance + * key. + * - `"bypass"`: bypass cache when the header appears in the origin `Vary` + * response header. + */ +type RequestInitCfPropertiesVaryAction = "normalize" | "passthrough" | "bypass"; +/** Configuration for Workers Standard Vary support. */ +interface RequestInitCfPropertiesVary { + /** The fallback action for varied request headers not listed in `headers`. */ + default: RequestInitCfPropertiesVaryHeader; + /** + * Lowercase request header names and their Vary configuration. + * + * The `accept` header can include `media_types`, the `accept-language` + * header can include `languages`, and other headers support only `action`. + */ + headers?: RequestInitCfPropertiesVaryHeaders; +} +/** Common Vary behavior for a single request header. */ +interface RequestInitCfPropertiesVaryHeader { + /** How this request header contributes to cache variance. */ + action: RequestInitCfPropertiesVaryAction; +} +/** Vary behavior for the `accept` request header. */ +interface RequestInitCfPropertiesVaryAcceptHeader extends RequestInitCfPropertiesVaryHeader { + /** + * Media types to keep when normalizing the `Accept` request header. + * + * Named `media_types` to match the serialized `cf.vary` configuration. + */ + media_types?: string[]; +} +/** Vary behavior for the `accept-language` request header. */ +interface RequestInitCfPropertiesVaryAcceptLanguageHeader extends RequestInitCfPropertiesVaryHeader { + /** + * Language tags to keep when normalizing the `Accept-Language` request + * header. + */ + languages?: string[]; +} +/** + * Lowercase request header names and their Vary behavior. + * + * The index signature allows arbitrary custom request headers beyond the + * well-known `accept` and `accept-language` specializations. + */ +interface RequestInitCfPropertiesVaryHeaders { + accept?: RequestInitCfPropertiesVaryAcceptHeader; + "accept-language"?: RequestInitCfPropertiesVaryAcceptLanguageHeader; + [header: string]: RequestInitCfPropertiesVaryHeader | RequestInitCfPropertiesVaryAcceptHeader | RequestInitCfPropertiesVaryAcceptLanguageHeader | undefined; +} +interface BasicImageTransformations { + /** + * Maximum width in image pixels. The value must be an integer. + */ + width?: number; + /** + * Maximum height in image pixels. The value must be an integer. + */ + height?: number; + /** + * When cropping with fit: "cover", this defines the side or point that should + * be left uncropped. The value is either a string + * "left", "right", "top", "bottom", "auto", or "center" (the default), + * or an object {x, y} containing focal point coordinates in the original + * image expressed as fractions ranging from 0.0 (top or left) to 1.0 + * (bottom or right), 0.5 being the center. {fit: "cover", gravity: "top"} will + * crop bottom or left and right sides as necessary, but won’t crop anything + * from the top. {fit: "cover", gravity: {x:0.5, y:0.2}} will crop each side to + * preserve as much as possible around a point at 20% of the height of the + * source image. + */ + gravity?: 'face' | 'left' | 'right' | 'top' | 'bottom' | 'center' | 'auto' | 'entropy' | BasicImageTransformationsGravityCoordinates; + /** + * Specifies how closely the image is cropped toward detected faces when combined + * with the gravity=face option. Accepts a valid range between 0.0 (includes as much + * of the background as possible) and 1.0 (crops the image as closely to the face as + * possible). The default is 0. + */ + zoom?: number; + /** + * Resizing mode as a string. It affects interpretation of width and height + * options: + * - scale-down: Similar to contain, but the image is never enlarged. If + * the image is larger than given width or height, it will be resized. + * Otherwise its original size will be kept. + * - scale-up: Similar to contain, but the image is never shrunk. If the + * image is smaller than the given width or height, it will be resized. + * Otherwise its original size will be kept. + * - contain: Resizes to maximum size that fits within the given width and + * height. If only a single dimension is given (e.g. only width), the + * image will be shrunk or enlarged to exactly match that dimension. + * Aspect ratio is always preserved. + * - cover: Resizes (shrinks or enlarges) to fill the entire area of width + * and height. If the image has an aspect ratio different from the ratio + * of width and height, it will be cropped to fit. + * - crop: The image will be shrunk and cropped to fit within the area + * specified by width and height. The image will not be enlarged. For images + * smaller than the given dimensions it's the same as scale-down. For + * images larger than the given dimensions, it's the same as cover. + * See also trim. + * - pad: Resizes to the maximum size that fits within the given width and + * height, and then fills the remaining area with a background color + * (white by default). Use of this mode is not recommended, as the same + * effect can be more efficiently achieved with the contain mode and the + * CSS object-fit: contain property. + * - squeeze: Stretches and deforms to the width and height given, even if it + * breaks aspect ratio + */ + fit?: "scale-down" | "scale-up" | "contain" | "cover" | "crop" | "pad" | "squeeze"; + /** + * Allows you to trim your image. Takes dpr into account and is performed before + * resizing or rotation. + * + * It can be used as: + * - left, top, right, bottom - it will specify the number of pixels to cut + * off each side + * - width, height - the width/height you'd like to end up with - can be used + * in combination with the properties above + * - border - this will automatically trim the surroundings of an image based on + * it's color. It consists of three properties: + * - color: rgb or hex representation of the color you wish to trim (todo: verify the rgba bit) + * - tolerance: difference from color to treat as color + * - keep: the number of pixels of border to keep + */ + trim?: "border" | { + top?: number; + bottom?: number; + left?: number; + right?: number; + width?: number; + height?: number; + border?: boolean | { + color?: string; + tolerance?: number; + keep?: number; + }; + }; + /** + * Background color to add underneath the image. Applies only to images with + * transparency (such as PNG). Accepts any CSS color (#RRGGBB, rgba(…), + * hsl(…), etc.) + */ + background?: string; + /** + * Flips the images horizontally, vertically, or both. Flipping is applied before + * rotation, so if you apply flip=h,rotate=90 then the image will be flipped + * horizontally, then rotated by 90 degrees. + */ + flip?: 'h' | 'v' | 'hv'; + /** + * Number of degrees (90, 180, 270) to rotate the image by. width and height + * options refer to axes after rotation. + */ + rotate?: 0 | 90 | 180 | 270 | 360; + /** + * Strength of sharpening filter to apply to the image. Floating-point + * number between 0 (no sharpening, default) and 10 (maximum). 1.0 is a + * recommended value for downscaled images. + */ + sharpen?: number; + /** + * Radius of a blur filter (approximate gaussian). Maximum supported radius + * is 250. + */ + blur?: number; + /** + * Increase contrast by a factor. A value of 1.0 equals no change, a value of + * 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is + * ignored. + */ + contrast?: number; + /** + * Increase brightness by a factor. A value of 1.0 equals no change, a value + * of 0.5 equals half brightness, and a value of 2.0 equals twice as bright. + * 0 is ignored. + */ + brightness?: number; + /** + * Increase exposure by a factor. A value of 1.0 equals no change, a value of + * 0.5 darkens the image, and a value of 2.0 lightens the image. 0 is ignored. + */ + gamma?: number; + /** + * Increase contrast by a factor. A value of 1.0 equals no change, a value of + * 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is + * ignored. + */ + saturation?: number; + /** + * Device Pixel Ratio. Default 1. Multiplier for width/height that makes it + * easier to specify higher-DPI sizes in <img srcset>. + */ + dpr?: number; + /** + * Adds a border around the image. The border is added after resizing. Border + * width takes dpr into account, and can be specified either using a single + * width property, or individually for each side. + */ + border?: { + color: string; + width: number; + } | { + color: string; + top: number; + right: number; + bottom: number; + left: number; + }; + /** + * Image segmentation using artificial intelligence models. Sets pixels not + * within selected segment area to transparent e.g "foreground" sets every + * background pixel as transparent. + */ + segment?: "foreground"; + /** + * Controls the algorithm used when an image needs to be enlarged. This + * parameter works with any fit mode that upscales, such as `contain`, + * `cover`, and `scale-up`. It has no effect when `fit=scale-down` or when + * the target dimensions are smaller than the source. + * - interpolate: Uses bicubic interpolation, which may reduce image quality. + * This is the default behavior when `upscale` is not specified. + * - generate: Uses AI upscaling to produce sharper, more detailed results + * when enlarging images. + */ + upscale?: "interpolate" | "generate"; +} +interface BasicImageTransformationsGravityCoordinates { + x?: number; + y?: number; + mode?: 'remainder' | 'box-center'; +} interface RequestInitCfPropertiesImageDraw extends BasicImageTransformations { /** * Absolute URL of the image file to use for the drawing. It can be any of @@ -11297,39 +11553,6 @@ interface RequestInitCfPropertiesImageDraw extends BasicImageTransformations { right?: number; } interface RequestInitCfPropertiesImage extends BasicImageTransformations { - /** - * Device Pixel Ratio. Default 1. Multiplier for width/height that makes it - * easier to specify higher-DPI sizes in <img srcset>. - */ - dpr?: number; - /** - * Allows you to trim your image. Takes dpr into account and is performed before - * resizing or rotation. - * - * It can be used as: - * - left, top, right, bottom - it will specify the number of pixels to cut - * off each side - * - width, height - the width/height you'd like to end up with - can be used - * in combination with the properties above - * - border - this will automatically trim the surroundings of an image based on - * it's color. It consists of three properties: - * - color: rgb or hex representation of the color you wish to trim (todo: verify the rgba bit) - * - tolerance: difference from color to treat as color - * - keep: the number of pixels of border to keep - */ - trim?: "border" | { - top?: number; - bottom?: number; - left?: number; - right?: number; - width?: number; - height?: number; - border?: boolean | { - color?: string; - tolerance?: number; - keep?: number; - }; - }; /** * Quality setting from 1-100 (useful values are in 60-90 range). Lower values * make images look worse, but load faster. The default is 85. It applies only @@ -11371,17 +11594,6 @@ interface RequestInitCfPropertiesImage extends BasicImageTransformations { * output formats always discard metadata. */ metadata?: "keep" | "copyright" | "none"; - /** - * Strength of sharpening filter to apply to the image. Floating-point - * number between 0 (no sharpening, default) and 10 (maximum). 1.0 is a - * recommended value for downscaled images. - */ - sharpen?: number; - /** - * Radius of a blur filter (approximate gaussian). Maximum supported radius - * is 250. - */ - blur?: number; /** * Overlays are drawn in the order they appear in the array (last array * entry is the topmost layer). @@ -11393,50 +11605,6 @@ interface RequestInitCfPropertiesImage extends BasicImageTransformations { * the origin. */ "origin-auth"?: "share-publicly"; - /** - * Adds a border around the image. The border is added after resizing. Border - * width takes dpr into account, and can be specified either using a single - * width property, or individually for each side. - */ - border?: { - color: string; - width: number; - } | { - color: string; - top: number; - right: number; - bottom: number; - left: number; - }; - /** - * Increase brightness by a factor. A value of 1.0 equals no change, a value - * of 0.5 equals half brightness, and a value of 2.0 equals twice as bright. - * 0 is ignored. - */ - brightness?: number; - /** - * Increase contrast by a factor. A value of 1.0 equals no change, a value of - * 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is - * ignored. - */ - contrast?: number; - /** - * Increase exposure by a factor. A value of 1.0 equals no change, a value of - * 0.5 darkens the image, and a value of 2.0 lightens the image. 0 is ignored. - */ - gamma?: number; - /** - * Increase contrast by a factor. A value of 1.0 equals no change, a value of - * 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is - * ignored. - */ - saturation?: number; - /** - * Flips the images horizontally, vertically, or both. Flipping is applied before - * rotation, so if you apply flip=h,rotate=90 then the image will be flipped - * horizontally, then rotated by 90 degrees. - */ - flip?: 'h' | 'v' | 'hv'; /** * Slightly reduces latency on a cache miss by selecting a * quickest-to-compress file format, at a cost of increased file size and @@ -11817,6 +11985,32 @@ interface IncomingRequestCfPropertiesTLSClientAuth { * @example "Dec 22 19:39:00 2018 GMT" */ certNotAfter: string; + /** + * The client leaf certificate in [RFC 9440](https://www.rfc-editor.org/rfc/rfc9440) + * format (`:base64-DER:`). Empty if no client certificate was presented or if + * the leaf certificate exceeded 10 KB (see {@link certRFC9440TooLarge}). + * + * Suitable for forwarding to an origin via the `Client-Cert` HTTP header. + */ + certRFC9440: string; + /** + * `true` if the leaf certificate exceeded 10 KB and was omitted from + * {@link certRFC9440}. + */ + certRFC9440TooLarge: boolean; + /** + * The intermediate certificate chain in [RFC 9440](https://www.rfc-editor.org/rfc/rfc9440) + * format as a comma-separated list. Empty if no intermediates were sent or + * if the chain exceeded 16 KB (see {@link certChainRFC9440TooLarge}). + * + * Suitable for forwarding to an origin via the `Client-Cert-Chain` HTTP header. + */ + certChainRFC9440: string; + /** + * `true` if the intermediate chain exceeded 16 KB and was omitted from + * {@link certChainRFC9440}. + */ + certChainRFC9440TooLarge: boolean; } /** Placeholder values for TLS Client Authorization */ interface IncomingRequestCfPropertiesTLSClientAuthPlaceholder { @@ -11837,6 +12031,10 @@ interface IncomingRequestCfPropertiesTLSClientAuthPlaceholder { certFingerprintSHA256: ""; certNotBefore: ""; certNotAfter: ""; + certRFC9440: ""; + certRFC9440TooLarge: false; + certChainRFC9440: ""; + certChainRFC9440TooLarge: false; } /** Possible outcomes of TLS verification */ declare type CertVerificationStatus = @@ -12024,6 +12222,13 @@ interface ForwardableEmailMessage extends EmailMessage { * @returns A promise that resolves when the email message is replied. */ reply(message: EmailMessage): Promise<EmailSendResult>; + /** + * Reply to the sender of this email message with a message built from the given + * fields. Threading headers (In-Reply-To/References) are set automatically. + * @param builder The reply message contents. + * @returns A promise that resolves when the email message is replied. + */ + reply(builder: EmailReplyMessageBuilder): Promise<EmailSendResult>; } /** A file attachment for an email message */ type EmailAttachment = { @@ -12044,23 +12249,46 @@ interface EmailAddress { name: string; email: string; } +/** + * Recipient fields for `SendEmail.send()`. At least one of `to`, `cc`, or + * `bcc` must be provided. + */ +type EmailDestinations = { + to?: string | EmailAddress | (string | EmailAddress)[]; + cc?: string | EmailAddress | (string | EmailAddress)[]; + bcc?: string | EmailAddress | (string | EmailAddress)[]; +} & ({ + to: string | EmailAddress | (string | EmailAddress)[]; +} | { + cc: string | EmailAddress | (string | EmailAddress)[]; +} | { + bcc: string | EmailAddress | (string | EmailAddress)[]; +}); +/** + * Fields shared by all composed emails (no recipients). Used directly by + * `ForwardableEmailMessage.reply()`, which always replies to the original + * sender, and extended by `EmailMessageBuilder` for `SendEmail.send()`. + */ +interface EmailReplyMessageBuilder { + from: string | EmailAddress; + subject: string; + replyTo?: string | EmailAddress; + headers?: Record<string, string>; + text?: string; + html?: string; + attachments?: EmailAttachment[]; +} +/** + * Fields for composing an email without constructing raw MIME, for + * `SendEmail.send()`. Requires at least one of `to`, `cc`, or `bcc`. + */ +type EmailMessageBuilder = EmailReplyMessageBuilder & EmailDestinations; /** * A binding that allows a Worker to send email messages. */ interface SendEmail { send(message: EmailMessage): Promise<EmailSendResult>; - send(builder: { - from: string | EmailAddress; - to: string | EmailAddress | (string | EmailAddress)[]; - subject: string; - replyTo?: string | EmailAddress; - cc?: string | EmailAddress | (string | EmailAddress)[]; - bcc?: string | EmailAddress | (string | EmailAddress)[]; - headers?: Record<string, string>; - text?: string; - html?: string; - attachments?: EmailAttachment[]; - }): Promise<EmailSendResult>; + send(builder: EmailMessageBuilder): Promise<EmailSendResult>; } declare abstract class EmailEvent extends ExtendableEvent { readonly message: ForwardableEmailMessage; @@ -12882,6 +13110,11 @@ declare namespace CloudflareWorkersModule { export type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; export type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number; export type WorkflowDelayDuration = WorkflowSleepDuration; + export type WorkflowDynamicDelayContext = { + ctx: WorkflowStepContext<WorkflowDelayFunction>; + error: Error; + }; + export type WorkflowDelayFunction = (input: WorkflowDynamicDelayContext) => WorkflowDelayDuration | Promise<WorkflowDelayDuration>; export type WorkflowTimeoutDuration = WorkflowSleepDuration; export type WorkflowRetentionDuration = WorkflowSleepDuration; export type WorkflowBackoff = 'constant' | 'linear' | 'exponential'; @@ -12889,12 +13122,13 @@ declare namespace CloudflareWorkersModule { export type WorkflowStepConfig = { retries?: { limit: number; - delay: WorkflowDelayDuration | number; + delay: WorkflowDelayDuration | number | WorkflowDelayFunction; backoff?: WorkflowBackoff; }; timeout?: WorkflowTimeoutDuration | number; sensitive?: WorkflowStepSensitivity; }; + export type WorkflowStepRollbackConfig = Pick<WorkflowStepConfig, 'retries' | 'timeout'>; export type WorkflowCronSchedule = { /** Cron expression that triggered this event. */ cron: string; @@ -12914,27 +13148,40 @@ declare namespace CloudflareWorkersModule { type: string; sensitive?: WorkflowStepSensitivity; }; - export type WorkflowStepContext = { + export type WorkflowStepContext<Delay = WorkflowDelayDuration | number> = { step: { name: string; count: number; }; attempt: number; - config: WorkflowStepConfig; + config: { + retries?: { + limit: number; + backoff?: WorkflowBackoff; + } & (Delay extends WorkflowDelayFunction ? {} : { + delay: WorkflowDelayDuration | number; + }); + timeout?: WorkflowTimeoutDuration | number; + sensitive?: WorkflowStepSensitivity; + }; }; export type WorkflowRollbackContext<T = unknown> = { + ctx: WorkflowStepContext; error: Error; output: T | undefined; + /** @deprecated Use `ctx.step.name` and `ctx.step.count` instead. */ stepName: string; }; export type WorkflowRollbackHandler<T = unknown> = (ctx: WorkflowRollbackContext<T>) => Promise<void>; export type WorkflowStepRollbackOptions<T = unknown> = { - rollback?: WorkflowRollbackHandler<T>; - rollbackConfig?: WorkflowStepConfig; + rollback: WorkflowRollbackHandler<T>; + rollbackConfig?: WorkflowStepRollbackConfig; }; export abstract class WorkflowStep { do<T extends Rpc.Serializable<T>>(name: string, callback: (ctx: WorkflowStepContext) => Promise<T>, rollbackOptions?: WorkflowStepRollbackOptions<T>): Promise<T>; - do<T extends Rpc.Serializable<T>>(name: string, config: WorkflowStepConfig, callback: (ctx: WorkflowStepContext) => Promise<T>, rollbackOptions?: WorkflowStepRollbackOptions<T>): Promise<T>; + do<T extends Rpc.Serializable<T>, const C extends WorkflowStepConfig>(name: string, config: C, callback: (ctx: WorkflowStepContext<C['retries'] extends { + delay: infer D; + } ? D : WorkflowDelayDuration | number>) => Promise<T>, rollbackOptions?: WorkflowStepRollbackOptions<T>): Promise<T>; sleep: (name: string, duration: WorkflowSleepDuration) => Promise<void>; sleepUntil: (name: string, timestamp: Date | number) => Promise<void>; waitForEvent<T extends Rpc.Serializable<T>>(name: string, options: { @@ -13825,7 +14072,7 @@ declare namespace TailStream { interface ConnectEventInfo { readonly type: "connect"; } - type EventOutcome = "ok" | "canceled" | "exception" | "unknown" | "killSwitch" | "daemonDown" | "exceededCpu" | "exceededMemory" | "loadShed" | "responseStreamDisconnected" | "scriptNotFound" | "internalError"; + type EventOutcome = "ok" | "canceled" | "exception" | "unknown" | "killSwitch" | "daemonDown" | "exceededCpu" | "exceededMemory" | "loadShed" | "responseStreamDisconnected" | "scriptNotFound" | "internalError" | "exceededWallTime"; interface ScriptVersion { readonly id: string; readonly tag?: string; @@ -13844,6 +14091,7 @@ declare namespace TailStream { readonly dispatchNamespace?: string; readonly entrypoint?: string; readonly executionModel: string; + readonly durableObjectId?: string; readonly scriptName?: string; readonly scriptTags?: string[]; readonly scriptVersion?: ScriptVersion; @@ -14398,6 +14646,13 @@ interface WorkflowError { code?: number; message: string; } +interface WorkflowInstanceTerminateOptions { + /** + * If true, run registered rollback handlers before terminating the instance. + * Only steps that registered rollback handlers are rolled back. + */ + rollback?: boolean; +} interface WorkflowInstanceRestartOptions { /** * Restart from a specific step. If omitted, the instance restarts from the beginning. @@ -14431,8 +14686,9 @@ declare abstract class WorkflowInstance { public resume(): Promise<void>; /** * Terminate the instance. If it is errored, terminated or complete, an error will be thrown. + * @param options Options for termination, including whether registered rollback handlers should run. */ - public terminate(): Promise<void>; + public terminate(options?: WorkflowInstanceTerminateOptions): Promise<void>; /** * Restart the instance. Optionally restart from a specific step, preserving * cached results for all steps before it. diff --git a/content-worker/wrangler.jsonc b/content-worker/wrangler.jsonc index c2a8431..aa517f0 100644 --- a/content-worker/wrangler.jsonc +++ b/content-worker/wrangler.jsonc @@ -7,6 +7,14 @@ // workers.dev origin (agentdraft-content.<subdomain>.workers.dev), separate from the // API/dashboard origin, so a CSP bypass in a published page cannot reach a session. // It NEVER sets a cookie and has NO auth bindings beyond read access to D1 + R2. + "vars": { + // The ONE origin allowed to embed a draft in an iframe (the dashboard preview). + // frame-ancestors must name it EXPLICITLY: 'self' means this content origin, which + // is NOT the dashboard origin, so 'self' alone blocks the preview outright. + // Keep this an exact origin — never widen it to a wildcard, since framing a draft + // is the one capability that lets another site wrap untrusted content in its own UI. + "DASHBOARD_ORIGIN": "https://app.agentdraft.tapetide.com" + }, "d1_databases": [ { "binding": "DB", diff --git a/dashboard/src/lib/Logo.svelte b/dashboard/src/lib/Logo.svelte new file mode 100644 index 0000000..b4d8750 --- /dev/null +++ b/dashboard/src/lib/Logo.svelte @@ -0,0 +1,60 @@ +<script lang="ts"> + // The agentdraft mark, inline as SVG rather than an <img> to the favicon. + // + // WHY INLINE: an <img src="/favicon.svg"> costs a second request and cannot inherit + // CSS custom properties. Inline SVG lets the accent bar use var(--accent), so the mark + // tracks a palette change instead of silently drifting from it. + // + // ACCESSIBILITY: decorative BY DEFAULT. The mark almost always sits beside the + // "agentdraft" wordmark, and labelling both made the nav link announce + // "agentdraft agentdraft" to a screen reader — verified in the accessibility tree. + // Pass `label` only when the mark stands alone with no adjacent text. + // + // Geometry is IDENTICAL to dashboard/static/favicon.svg (32-unit viewBox, even bar + // heights and gaps). Change one and you must change both; scripts/verify_icons.py + // asserts the static assets stay consistent. + let { + size = 22, + label = "", + }: { size?: number; label?: string } = $props(); +</script> + +<svg + class="logo" + width={size} + height={size} + viewBox="0 0 32 32" + role={label ? "img" : undefined} + aria-label={label || undefined} + aria-hidden={label ? undefined : "true"} + focusable="false" +> + <!-- Tile sits one step ABOVE --bg. A tile filled with --bg on a --bg nav is invisible, + which made the mark read as bare floating bars rather than a badge. --> + <rect width="32" height="32" rx="7" fill="#141414" /> + <!-- Edge must survive downscaling: the viewBox is 32 units rendered at ~22px, so a + 1-unit stroke lands on ~0.7px and disappears. 1.5 units + a light stroke keeps the + badge shape legible; --border (#2a2a2a) on #0a0a0a was too dark to resolve at all. --> + <rect + x="0.75" + y="0.75" + width="30.5" + height="30.5" + rx="6.4" + fill="none" + stroke="rgba(255, 255, 255, 0.26)" + stroke-width="1.5" + /> + <g fill="var(--fg)"> + <rect x="6" y="6" width="20" height="4" /> + <rect x="6" y="14" width="20" height="4" /> + </g> + <rect x="6" y="22" width="10" height="4" fill="var(--accent)" /> +</svg> + +<style> + .logo { + display: block; + flex: none; /* never let flex squash the mark into an ellipse */ + } +</style> diff --git a/dashboard/src/lib/styles.css b/dashboard/src/lib/styles.css index f148f91..9722a75 100644 --- a/dashboard/src/lib/styles.css +++ b/dashboard/src/lib/styles.css @@ -191,8 +191,16 @@ a:hover { color: var(--fg); text-transform: lowercase; /* lowercase logo vs uppercase headings — deliberate */ text-decoration: none; + /* Mark + wordmark are one lockup: flex keeps them on a shared baseline and stops the + glyph drifting when the nav wraps at narrow widths. */ + display: inline-flex; + align-items: center; + gap: 0.5rem; } -.nav .brand span { +/* Scoped to .brand__word deliberately. A bare `.nav .brand span` would also match the + wordmark WRAPPER (and any <rect> wrapper inside the inline SVG logo), painting the + whole word accent instead of just "draft". */ +.nav .brand .brand__word span { color: var(--accent); } .nav a:not(.brand) { diff --git a/dashboard/src/routes/+layout.svelte b/dashboard/src/routes/+layout.svelte index 58d5149..445e62b 100644 --- a/dashboard/src/routes/+layout.svelte +++ b/dashboard/src/routes/+layout.svelte @@ -2,6 +2,7 @@ import "$lib/styles.css"; import { page } from "$app/state"; import Footer from "$lib/Footer.svelte"; + import Logo from "$lib/Logo.svelte"; let { children, data } = $props(); const path = $derived(page.url.pathname); </script> @@ -17,7 +18,10 @@ <nav class="nav"> <div class="nav__inner"> - <a class="brand" href="/">agent<span>draft</span></a> + <a class="brand" href="/"> + <Logo size={22} /> + <span class="brand__word">agent<span>draft</span></span> + </a> {#if data.authed} <a href="/dashboard" class:active={path === "/dashboard"}>Drafts</a> <a href="/projects" class:active={path.startsWith("/projects")}>Projects</a> diff --git a/dashboard/static/logo-readme.svg b/dashboard/static/logo-readme.svg new file mode 100644 index 0000000..37ba26b --- /dev/null +++ b/dashboard/static/logo-readme.svg @@ -0,0 +1,16 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="agentdraft"> + <!-- README / docs variant. Same mark, but the tile sits one step above the app + background and the edge is a light stroke, because this renders on GitHub's WHITE + or dark canvas rather than on our own black nav: a #0a0a0a tile with a #2a2a2a + edge disappears on both. Rounded (unlike icon.svg) since nothing crops it here. + NOTE: no double hyphen anywhere in this comment. XML forbids it, and writing the + CSS variable name for the background here made librsvg reject the whole file. --> + <rect width="32" height="32" rx="7" fill="#141414"/> + <rect x="0.75" y="0.75" width="30.5" height="30.5" rx="6.4" fill="none" + stroke="rgba(255,255,255,0.26)" stroke-width="1.5"/> + <g fill="#ffffff"> + <rect x="6" y="6" width="20" height="4"/> + <rect x="6" y="14" width="20" height="4"/> + </g> + <rect x="6" y="22" width="10" height="4" fill="#d6f520"/> +</svg> diff --git a/docs/assets/logo.png b/docs/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..730a2ff56739c396712532082531d67a6dca470d GIT binary patch literal 2674 zcma)8XH=8P8V)TBk+yEoP^GN|7U@zHX&NAiNFpF2U}z#mS_EQ32Ny*mNXHP07?5D- zm<5+65;`m(M0&|0a_O)MARzbi{<!Dvk3HvpbH155=RME7^PZV`p67|JjfD_I0s;bo zgpih3(7=fM{_}%@-oR6L7#P6rS1qo9j=#6UrqXl}2%?0%VrqxWTO;50R~QwKIX2K{ z;TEb{ehd2g!K=<$V(vvR`Cj~>s7BcwKK*5lc9LI(U8HUq={WI{iXP22vd^63Dx;~a zHQQ00K5@TPf8`%&n^0r*`xc{Z>)hVSJDpp(2J4r@QDZVn&(z;_cON(HPkqjA$=k}^ zDM0bU<l8VB8rs@BZ>ZEeatW&4HHH@a!H_u2&k2rvrm)Y8Hs@##Ctw~#hw@QO@*2vD zF77epMB}8`sj$a5oG`p2Q({lDAU{8!L?RJ}0_f3KQ5k$FXyT8|+zt)F*US}W@8b3% znM`KhDEsIgpUkAsb#!zjmX?&bVlR-dch^{h1-La6(LEdvXJ&eO`Y2dO;>g?BL+bYC z)YQ~rUteFKq0Hb&nj#x}?a!PqjlvDi>pf#*W4qe9)`$9$DOh7MHRs=gG@%fekOt=^ z{x&3`R5l@oN<ATyvgAeTXVi?Z`c_5~UbMxUv`Pws)%TCSb*3o19ruqb@i9^6W56^R z`1M}x1W^E`+FJ;RII3plA80XT)4L<8>g(B4>+1oW*BlE-KA9|JA^*I^fIKi>Jb|{7 z%kDL;c1#Fq5X=~MWf_fh)d&hF@;IxV#x=R7dhK@^Ju-9i#$tD#cxs7XdNW-Vcly++ zW+>#*O!eeie*1*IikPM1Nn1FyaMV{?TxwX_jQ_Xk0Df?MA@yHH_j?yZq6mb8vi|=5 zly~*ZD*95w>c6(ni-@F8-qVpi_0+V;_KPy^PaMurhCi_#Q}6Wdt#YPS2b;~VEJ2rr z*5XGHS+g6v2w=b0t*!Uo3FTQ)<|gX>Y%7^5()z=aFG@^xNqoq()&M%`ZM74vXK!y$ z4^C+*w^NbU>f=4JI~_iICemm&{w|Le0}~l3oz|^5^K1hITn5M^?t3r50d&c}Fdi{P zt|;$(Z@1?*eJxZvP8~Rjt$f1z!#00e7pB|&`$9<_3snmi_*br#mnmqjlun+z*R`nB zffOQf&AWD7L^x~qEDn#y2e`S-r**h5J2EsG7yxEP8U7>Q0$)6I&jf%K3^Fy-`Ak6w zlmna+4bQY9(0c+8ZsZgcM7yA%9sZj{qEH5b8wiLJUl@xkC%Fspa<>>caykTEU0tK# zFu`weqQk*~HWYNK_1Q!j)!{R2ytL}6r_1kqee2cUI?;Z4WF{vNgaKc-vH9kl_DZ;c zXwI{+wFhS*zGD3RPDl{VEgBS&8U_aY>9-36KqmT9`Q)4&+^~P4qbm4Uw`5h@SH^<v zF_L-#Ib#jZ6{S3}t*Wip+HTyqp-!S4s$@a}-19Ko`-XPqqt8NaWg#_S5nOX67<>By zJxST;mdQFMo<WTUXnQsZ1X6?vf}w~fb$W#v3p?w|g|_gVv!VXFlV~_}J3Tiy*BgOa zb#z_~F|ghsbHhARdlxVCAW+c5sAJ}n{=0-vZxjY*MlS2!)FrL`h=g_^YI{N&4F5pf zba23Ud3hCT4nWGw%c~kEcI1e{jSXpvKFBmLgO9G&EiJ}DHBnw(e69}{Gh-S0R|VyB z(2)MSwJguaiby##{@i;T7!HV-=dW{Z1^yFa$duRg%O?V(ShrNfh1s8UKS5M$%r&We zX(VwxKtb+<Ro`}VPM2|Hj=@j7T$@4KONE#L)3&_$ID;Sfu)>hfQqVpFcn;Q9V<~x6 z?8vl6!JCMRx~U+RoVH<vd-^}&f5ZU+@msnAvudkk)ZH0#71)1f0*u=_|D5Y@i#5C8 zxLfHbo;SDszmS2h8l;Gk{Cl31q+*HYz?nn)oq)t33-R2!A7sSl3C49&)0@KBF<#cW z7vC%UTekm}Y)L<sBIc<z9w~*thfM}n%JM34d@e&T?jDJI2yug)2#-{I!{yI<KDJEr zaVjzwY=_rpt0ZZCeVkh4{<%*hx#o&8@wRGKL%i6jr+l8bz*au9rw=z0!M_W5|ANKR zQ@zAhvj+Oe6)VBr8p$i5O-BNyeLg0_Xm%H2-0a)hd6U?DpwPq$(zosnbxBFXH${!U z8D0z_>cBm500)h1JJG^2UyUrcz<wRtdh;h6AACL%0?=b8YZ<O5Se)7IS&@n}%pcM@ zH#TFEwOA1?-qqc<7Dh5+fAp=t2hg=BD8`Bs()|Xj0h0>Gbrn9q+R+@;d8VeQQ!_JB z@#vlj$ueqE?H(^{3uj^vJP9l1tBNNBK~vB?n^O8Npakb76;D3NSx=J<KaGOQNePq2 z#GZEiQf8;Z6(l>pxxZ9!=VNG-N3~bIlUr0&lxM8`k*2Y+h(mdOaUL47{NQUT-6&cP z=i$Mc1@uC?wymVJv~6jJC~xyh<gAjC8y-JVr<UE@6v|={qo=<{Ok!;fWulQIb<di7 zKrs}`&k?#tT>4}iM`vdpS~+ut_~61(e7qO^=r=$IhxhjMziU@rX#uY(h(0Ma>MJHe zWK^-t@vX}b-Z$;eOrFE?Uc7kmw4Sc+w}`6Wi@kJ7E}`$HQ>au4pGt*<n8BrZ^I|SG zKJs;&LZY^TfxyxLd8Kjg8U?)({nwC%dVMzOty-p4;6?Jl20Te?$W@7-8&H)hM^#&u z%+}Rbke1;URDz`<caiPu71QJhqvP??f~c>awd1Qfp(Syo{t^c4Ae~B`m6nv0)bsP8 zPXfiaefwtgV1L{k{DHOAy1%m|;&PB-MOk34FwX#LbMY?Q<xCJT0SI*$ig;K-@&wdj zX(zUm#pS{*8oD=^ER%Yh&<lKkT^Kp{Tm%FHI4IjvF+aJJ<u^Sc^>EKOEKFBQIB_<- z!5M2&PsuP-%Z_)i=3fkXSQxQ=%Ph+)NMC9$q@i_%&3W+hY{B5h2lr~Fv~JJ8s=_Xj zD{28*+*umD!#OxgIiuA6(O=heM5i!=+d~kx@=54Yx~Q266p^YZ07YnC8w-!u1m{|R zZt`PRM7~3pN##}<4{Ralza4BGqhv*K_HEji!uhU^)B)!<p;QpDeHHX&nZD^r>Kpgx z!~_0?fK1DxIjnHY@eVR)xZo5TU_I!?LtnH38xts98aNyGetz?EtjV0WP3ewbjVh}< z=0_7{uMYREN+9j1qbjE$iUGrc!Q8Rq7>&J`RO;ZQ|J$EY&|n9Vn2Hq|zDb7WP@etM zlMj;g8nYuUJMkjhF5~m(*~wI@$kntuMI`#MsFoc6dwsbNNrBdf1rFtW$<^lNqf~8% tRbxdb@Gi%E>&t=NuF+_;)v5}%@mD@dbBpYSTY!lKArUrLD$Fo({{||a82<nO literal 0 HcmV?d00001 diff --git a/scripts/gen-icons.sh b/scripts/gen-icons.sh index 045e093..b229a2d 100755 --- a/scripts/gen-icons.sh +++ b/scripts/gen-icons.sh @@ -28,6 +28,10 @@ trap 'rm -rf "$tmp"' EXIT for s in 16 32 48; do rsvg-convert -w $s -h $s "$STATIC/favicon.svg" -o "$tmp/i-$s.png"; done convert "$tmp/i-16.png" "$tmp/i-32.png" "$tmp/i-48.png" "$STATIC/favicon.ico" +echo "==> README/docs logo (transparent corners so it reads on GitHub light AND dark)" +mkdir -p docs/assets +rsvg-convert -w 120 -h 120 "$STATIC/logo-readme.svg" -o docs/assets/logo.png + echo "==> content-worker embedded icons (16/32 only — keeps the worker bundle small)" for s in 16 32; do rsvg-convert -w $s -h $s "$STATIC/favicon.svg" -o "$tmp/c-$s.png"; done convert "$tmp/c-16.png" "$tmp/c-32.png" "$tmp/content.ico" diff --git a/scripts/verify_icons.py b/scripts/verify_icons.py index 380a101..21bb181 100644 --- a/scripts/verify_icons.py +++ b/scripts/verify_icons.py @@ -46,12 +46,30 @@ def main() -> int: print("== files present ==") expected = [ - "favicon.svg", "icon.svg", "favicon.ico", "apple-touch-icon.png", - "icon-192.png", "icon-512.png", "site.webmanifest", + "favicon.svg", "icon.svg", "logo-readme.svg", "favicon.ico", + "apple-touch-icon.png", "icon-192.png", "icon-512.png", "site.webmanifest", ] for name in expected: ok((STATIC / name).is_file(), f"{name} exists") + print("== README/docs logo ==") + readme_png = ROOT / "docs" / "assets" / "logo.png" + ok(readme_png.is_file(), "docs/assets/logo.png exists") + if readme_png.is_file(): + rim = Image.open(readme_png).convert("RGBA") + rpx = rim.load() + # Corners MUST stay transparent: GitHub renders README images on a white canvas + # in light mode and near-black in dark mode. An opaque square would show as a + # pale or dark box around the rounded badge in one of the two themes. + corner_alpha = rpx[0, 0][3] + ok(corner_alpha == 0, "logo.png corners transparent", f"alpha={corner_alpha}") + colors = {c[:3] for _, c in rim.getcolors(maxcolors=99999) or []} + ok((214, 245, 32) in colors, "logo.png carries the accent colour") + # The mark is embedded in README.md by relative path; a rename breaks the render + # silently (GitHub shows a broken-image glyph, not an error). + readme = (ROOT / "README.md").read_text() + ok("docs/assets/logo.png" in readme, "README references the logo") + print("== favicon.ico is multi-resolution ==") ico = Image.open(STATIC / "favicon.ico") sizes = sorted(ico.info.get("sizes", [])) From 8ee4b3ce9900493dcdeb70a290ebfac9eb453091 Mon Sep 17 00:00:00 2001 From: Hitesh Sisara <hitesh.dev@sisara.in> Date: Sat, 8 Aug 2026 11:39:05 +0000 Subject: [PATCH 5/9] fix(csp): CORP was a second embedding gate that kept the preview broken The preview iframe stayed refused after frame-ancestors was fixed, because Cross-Origin-Resource-Policy: same-origin blocks cross-origin embedding INDEPENDENTLY of the CSP. Two gates, one visible symptom -- an identical blank frame -- which is exactly how the first fix passed verification while prod stayed broken. Now same-site rather than removed: both origins are tapetide.com subdomains, so the dashboard can embed a draft while an arbitrary third-party site still cannot hotlink one. COOP stays same-origin; it governs window.opener, not framing. Verified against the direct workers.dev origin (the custom domain served a stale zone-cached copy for ~1 min after deploy, which is its own trap when confirming a header change). --- content-worker/src/index.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/content-worker/src/index.ts b/content-worker/src/index.ts index 3ef86f7..0b76a69 100644 --- a/content-worker/src/index.ts +++ b/content-worker/src/index.ts @@ -75,7 +75,18 @@ function securityHeaders( "X-Content-Type-Options": "nosniff", "X-Robots-Tag": "noindex, nofollow", "Referrer-Policy": "no-referrer", - "Cross-Origin-Resource-Policy": "same-origin", + // CORP is a SECOND, INDEPENDENT embedding gate. `same-origin` blocks the dashboard + // preview iframe even when frame-ancestors already allows it — fixing only the CSP + // left the frame refused with an IDENTICAL blank-frame symptom, which is how the + // first fix passed verification while the preview stayed broken in prod. + // + // `same-site` (not a removal) because both origins are tapetide.com subdomains: the + // dashboard can embed a draft, while an arbitrary third-party site still cannot + // hotlink one. Dropping CORP altogether would open embedding to everyone. + "Cross-Origin-Resource-Policy": "same-site", + // COOP governs WINDOW references (window.opener), not framing, so it stays strict. + // Loosening it would buy nothing for the preview and weaken isolation of untrusted + // content. "Cross-Origin-Opener-Policy": "same-origin", "Permissions-Policy": "geolocation=(), microphone=(), camera=(), payment=()", ...extra, From 3b946b071b2ced68f3a37e536bc096d6db530f22 Mon Sep 17 00:00:00 2001 From: Hitesh Sisara <hitesh.dev@sisara.in> Date: Sat, 8 Aug 2026 13:46:54 +0000 Subject: [PATCH 6/9] feat(privacy): private drafts + 113-bit draft ids Every published draft was readable by anyone holding its URL, forever, with no revocation. is_public already existed in the schema and the content worker already enforced it -- but NOTHING could ever set it. This makes it settable, from the CLI and the dashboard, per draft and per account. HOW A PRIVATE DRAFT IS VIEWED. The shared URL is unchanged and just opens for its owner. The content origin stays cookie-free (it serves attacker-controlled HTML, so a session must never be readable there), so it cannot authorize anyone: it 302s to the dashboard, which holds the session, verifies OWNERSHIP, and streams the same bytes. Signed-out viewers land on sign-in and return to the exact document via a validated ?next=. Owner-only, not any-signed-in-user. Widening access later is easy; un-leaking a draft is not. NOT RETROACTIVE. The account default applies to NEW drafts only. Flipping a preference must never silently break links already shared with reviewers, so existing drafts change only via an explicit bulk endpoint, which reports how many rows it touched. IDS: 12 -> 22 chars base36 (62 -> 113 bits). Measured first: 62 bits was already not enumerable (~7.5 years to a first hit at 100k req/s against 100k drafts, before rate limiting), so this is defence in depth and NOT the privacy mechanism -- a leaked URL is readable at any entropy. Existing 12-char ids keep working and are not migrated; people already shared them. The ?next= open-redirect guard is the risky part, so it is allow-list based and tested (31 cases: scheme-relative, backslash, encoded, control chars). MUTATION TESTED, which changed what I believe: deleting the startsWith("//") check leaves all 31 tests GREEN, because a string starting "//" can never start "/private/d/" either -- the allow-list is the load-bearing defence and the "//" check is redundant. Deleting the allow-list fails 2 tests at once. That finding is recorded in the test file so nobody relaxes the allow-list believing the "//" check protects them. Wired into CI. Verified against live prod, not just locally: anonymous request to a private draft returns 302 with zero content bytes; following it anonymously lands on sign-in with next= preserved; owner reads 200 with content; a DIFFERENT account's key gets 404 (not 403 -- existence is not disclosed); no-auth gets 401; /raw and /v/N redirect too; ?v=abc 400s and ?v=999 404s; flipping back to public restores anonymous reads (revocable both directions, no stale 302 cached). Migration confirmed non-destructive: 95 drafts before and after, 0 privatised. Bulk call touched only my 4 drafts, the other account's 90 untouched. --- .github/workflows/dashboard.yml | 4 + content-worker/src/index.ts | 43 +++++- dashboard/bun.lock | 47 ++++++ dashboard/package.json | 2 + dashboard/src/lib/server/next.test.ts | 83 ++++++++++ dashboard/src/lib/server/next.ts | 40 +++++ dashboard/src/routes/auth/google/+server.ts | 28 +++- .../routes/auth/google/callback/+server.ts | 9 +- dashboard/src/routes/login/+page.server.ts | 14 +- dashboard/src/routes/login/+page.svelte | 11 +- .../src/routes/private/[...path]/+server.ts | 100 ++++++++++++ worker/migrations/0004_private_drafts.sql | 32 ++++ worker/src/index.ts | 41 ++++- worker/src/routes/drafts.ts | 142 +++++++++++++++++- worker/src/routes/upload.ts | 19 ++- worker/src/services/id.ts | 12 +- worker/src/types.ts | 3 + 17 files changed, 616 insertions(+), 14 deletions(-) create mode 100644 dashboard/src/lib/server/next.test.ts create mode 100644 dashboard/src/lib/server/next.ts create mode 100644 dashboard/src/routes/private/[...path]/+server.ts create mode 100644 worker/migrations/0004_private_drafts.sql diff --git a/.github/workflows/dashboard.yml b/.github/workflows/dashboard.yml index 74cdf7f..5c4bcf4 100644 --- a/.github/workflows/dashboard.yml +++ b/.github/workflows/dashboard.yml @@ -20,4 +20,8 @@ jobs: bun-version: latest - run: bun install - run: bun run check + # The open-redirect guard on ?next= is security-critical and only provable by test: + # a comment claiming a value is validated is not evidence. Runs before build so a + # regression fails fast. + - run: bun run test - run: bun run build diff --git a/content-worker/src/index.ts b/content-worker/src/index.ts index 0b76a69..cb4e727 100644 --- a/content-worker/src/index.ts +++ b/content-worker/src/index.ts @@ -171,7 +171,48 @@ export default { .first<DraftRow>(); if (!draft || draft.deleted_at) return textResponse(404, "Not Found"); - if (!draft.is_public) return textResponse(403, "This draft is not public."); + + // PRIVATE DRAFT -> hand off to the dashboard, never serve it here. + // + // This worker is cookie-free BY DESIGN: it serves attacker-controlled HTML, so a + // session must never be readable on this origin. It therefore cannot authorize a + // viewer itself. Instead it redirects to the dashboard, which holds the session, + // verifies OWNERSHIP, and streams the same bytes from its own origin. + // + // The user experience is "the same link just opens when I'm signed in": the shared + // URL is unchanged, and the redirect is invisible in normal browsing. A signed-out + // viewer lands on sign-in and returns to this exact draft afterwards. + // + // 302 not 301: visibility is a mutable property. A permanent redirect would be + // cached by browsers and intermediaries, so a draft later made public again would + // keep bouncing to the dashboard from stale caches. + // + // Known and accepted: this distinguishes "private" from "nonexistent" to anyone + // probing ids, i.e. an existence oracle. With 113-bit ids (and 62-bit legacy ids) + // guessing an id is infeasible, so the oracle is not exploitable, and collapsing + // private into 404 would make a genuine typo indistinguishable from someone else's + // private draft — worse for users, no real gain. + if (!draft.is_public) { + const dash = env.DASHBOARD_ORIGIN?.replace(/\/$/, ""); + if (!dash) { + // Fail CLOSED. With no dashboard configured there is nowhere to authorize the + // viewer, and serving the bytes would publish a draft the owner marked private. + return textResponse(403, "This draft is private."); + } + // Preserve the full path so /v/2 and /raw survive the hand-off and the viewer + // lands on the exact representation they asked for. + const target = `${dash}/private${url.pathname}${url.search}`; + return new Response(null, { + status: 302, + headers: { + Location: target, + // A private draft must never be cached by a shared cache: the next viewer + // may be a different principal with different rights. + "Cache-Control": "private, no-store", + "X-Robots-Tag": "noindex, nofollow", + }, + }); + } if (draft.disabled_at) { return textResponse(451, `This document has been disabled. ${draft.disabled_reason ?? ""}`.trim()); } diff --git a/dashboard/bun.lock b/dashboard/bun.lock index 0af310d..7670590 100644 --- a/dashboard/bun.lock +++ b/dashboard/bun.lock @@ -12,6 +12,7 @@ "svelte-check": "4.7.4", "typescript": "5.7.2", "vite": "8.2.1", + "vitest": "^4", "wrangler": "4.118.0", }, }, @@ -209,24 +210,48 @@ "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.2.0", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="], + + "@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], + + "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], + + "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], @@ -237,12 +262,18 @@ "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + "es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="], + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], "esrap": ["esrap@2.3.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-40GyiEJevYKXzYTHtZkFqAgTjLOuFcaXMao8TPyOlnWTlkHDlvZ6mPMJaJyOqVwrVCgomEG1WhJd81w0X+IcCw=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -313,18 +344,30 @@ "sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], "svelte": ["svelte@5.56.8", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg=="], "svelte-check": ["svelte-check@4.7.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.1", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-IW9ot9YqAoyv8FvyN+eb4ZTe8zgcKZrJLNYU6dzSKkGwEBsSPc4K7lmQ8bKn8W2YMXM6WDfZSSVOaGtekyUfOQ=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -339,6 +382,10 @@ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "workerd": ["workerd@1.20260730.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260730.1", "@cloudflare/workerd-darwin-arm64": "1.20260730.1", "@cloudflare/workerd-linux-64": "1.20260730.1", "@cloudflare/workerd-linux-arm64": "1.20260730.1", "@cloudflare/workerd-windows-64": "1.20260730.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA=="], "worktop": ["worktop@0.8.0-next.18", "", { "dependencies": { "mrmime": "^2.0.0", "regexparam": "^3.0.0" } }, "sha512-+TvsA6VAVoMC3XDKR5MoC/qlLqDixEfOBysDEKnPIPou/NvoPWCAuXHXMsswwlvmEuvX56lQjvELLyLuzTKvRw=="], diff --git a/dashboard/package.json b/dashboard/package.json index e2800de..df4ae07 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -8,6 +8,7 @@ "build": "vite build && cp .assetsignore .svelte-kit/cloudflare/.assetsignore", "preview": "vite preview", "deploy": "wrangler deploy", + "test": "vitest run", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" }, "devDependencies": { @@ -18,6 +19,7 @@ "svelte-check": "4.7.4", "typescript": "5.7.2", "vite": "8.2.1", + "vitest": "^4", "wrangler": "4.118.0" } } diff --git a/dashboard/src/lib/server/next.test.ts b/dashboard/src/lib/server/next.test.ts new file mode 100644 index 0000000..4e91062 --- /dev/null +++ b/dashboard/src/lib/server/next.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { safeNext } from "./next"; + +// An unvalidated `next` is an OPEN REDIRECT: our own sign-in page bounces a victim +// off-site after they authenticate, with our domain lending it credibility. This suite +// exists to prove the guard REJECTS things, so every case below asserts the fallback. +// +// WHICH GUARD IS LOAD-BEARING (established by mutation testing, not by reading the code). +// Deleting `startsWith("//")` from safeNext leaves ALL 31 tests passing, because a string +// beginning "//" can never begin "/private/d/" either — the ALLOW-LIST is what actually +// rejects scheme-relative URLs. Deleting the allow-list instead fails 2 tests immediately. +// So the allow-list is the real defence and the "//" check is redundant belt-and-braces. +// +// That matters for anyone editing this: if you ever relax ALLOWED_PREFIXES to something +// permissive, the "//" check alone will NOT save you from the encoded and backslash +// variants below. Re-run the allow-list mutation after any change here. + +const FALLBACK = "/dashboard"; + +describe("safeNext — hostile input must collapse to the fallback", () => { + const hostile: [string, string][] = [ + ["absolute http", "http://evil.example/"], + ["absolute https", "https://evil.example/"], + ["scheme-relative (the most-missed case)", "//evil.example/"], + ["scheme-relative with path", "//evil.example/private/d/abc123"], + ["backslash escape", "/\\evil.example"], + ["double backslash", "\\\\evil.example"], + ["javascript scheme", "javascript:alert(1)"], + ["data scheme", "data:text/html,<script>alert(1)</script>"], + ["encoded double slash", "/%2f%2fevil.example"], + ["encoded backslash", "/%5cevil.example"], + ["embedded newline", "/private/d/abc\n//evil.example"], + ["embedded CR", "/private/d/abc\r//evil.example"], + ["embedded tab", "/private/d/abc\t"], + ["leading space", " /private/d/abc123"], + ["NUL byte", "/private/d/abc\u0000"], + ["protocol-ish colon", "/private/d/a:b"], + ["not a served prefix", "/etc/passwd"], + ["bare relative", "private/d/abc123"], + ["empty string", ""], + ["just a slash", "/"], + ]; + + for (const [label, input] of hostile) { + it(`rejects ${label}`, () => { + expect(safeNext(input)).toBe(FALLBACK); + }); + } + + it("rejects null and undefined", () => { + expect(safeNext(null)).toBe(FALLBACK); + expect(safeNext(undefined)).toBe(FALLBACK); + }); +}); + +describe("safeNext — legitimate return targets must survive", () => { + const allowed = [ + "/private/d/abc123def456", + "/private/d/abc123def456/v/2", + "/private/d/abc123def456/raw", + // 22-char id (the new higher-entropy format) + "/private/d/abcdefghij0123456789kl", + "/drafts/abc123def456", + "/dashboard", + "/projects", + "/settings/keys", + ]; + + for (const path of allowed) { + it(`preserves ${path}`, () => { + expect(safeNext(path)).toBe(path); + }); + } + + it("honours a custom fallback", () => { + expect(safeNext("http://evil.example", "/login")).toBe("/login"); + }); + + it("returns an empty fallback when asked (used to mean 'no next')", () => { + expect(safeNext(null, "")).toBe(""); + expect(safeNext("//evil.example", "")).toBe(""); + }); +}); diff --git a/dashboard/src/lib/server/next.ts b/dashboard/src/lib/server/next.ts new file mode 100644 index 0000000..bb94602 --- /dev/null +++ b/dashboard/src/lib/server/next.ts @@ -0,0 +1,40 @@ +// Validate a `?next=` return-to path. +// +// WHY THIS IS ITS OWN MODULE. An unvalidated `next` is a textbook OPEN REDIRECT: an +// attacker sends `/login?next=https://evil.example/` and our own sign-in page bounces the +// victim off-site after they authenticate, with our domain in the referrer chain lending +// it credibility. +// +// Rules, all of which must hold: +// * must start with a single "/" — anything else is absolute or scheme-relative +// * must NOT start with "//" — that is scheme-relative and resolves OFF-ORIGIN +// (https://evil.com), the single most-missed case +// * must NOT contain a backslash — some browsers normalise "\" to "/", so "/\evil.com" +// has historically escaped naive checks +// * must NOT contain a control character or whitespace — used to smuggle past filters +// * must be a path we actually serve a document from +// +// Returns the safe path, or the fallback when anything is off. Never throws: a malformed +// `next` should quietly land the user somewhere sensible, not error. + +const FALLBACK = "/dashboard"; + +// Only these prefixes are accepted as return targets. An allow-list beats a deny-list +// here: a new externally-reachable route cannot accidentally become a redirect target. +const ALLOWED_PREFIXES = ["/private/d/", "/drafts/", "/dashboard", "/projects", "/settings"]; + +export function safeNext(raw: string | null | undefined, fallback = FALLBACK): string { + if (!raw) return fallback; + // Reject control chars, spaces, tabs, newlines outright. + if (/[\u0000-\u0020\u007f]/.test(raw)) return fallback; + if (raw.includes("\\")) return fallback; + if (!raw.startsWith("/")) return fallback; + if (raw.startsWith("//")) return fallback; + // Defensive: a URL-encoded scheme or host that survived one decode pass. + const lowered = raw.toLowerCase(); + if (lowered.includes(":") || lowered.includes("%2f%2f") || lowered.includes("%5c")) { + return fallback; + } + if (!ALLOWED_PREFIXES.some((p) => raw === p || raw.startsWith(p))) return fallback; + return raw; +} diff --git a/dashboard/src/routes/auth/google/+server.ts b/dashboard/src/routes/auth/google/+server.ts index 53f8e90..526c3a1 100644 --- a/dashboard/src/routes/auth/google/+server.ts +++ b/dashboard/src/routes/auth/google/+server.ts @@ -1,13 +1,39 @@ import { redirect } from "@sveltejs/kit"; import type { RequestHandler } from "./$types"; import { apiFetch } from "$lib/server/api"; +import { safeNext } from "$lib/server/next"; // GET /auth/google — kick off Google sign-in. // // The BFF asks the API for the authorize URL (the API owns the client id, PKCE verifier // and state) and then redirects the browser to Google. The dashboard never handles the // client secret. -export const GET: RequestHandler = async ({ locals }) => { +export const GET: RequestHandler = async ({ locals, url, cookies }) => { + // Carry the return-to path across the Google round trip. + // + // It is stashed in a short-lived httpOnly cookie rather than appended to the OAuth + // `state`: `state` is minted and verified by the API, and stuffing caller-controlled + // data into it would mean the API has to parse and re-trust a value that exists purely + // for CSRF. Keeping `next` on the dashboard origin keeps that boundary clean. + // + // Validated on the way IN as well as on the way out. Storing a hostile value and + // sanitising later means one missed read is an open redirect. + const next = safeNext(url.searchParams.get("next"), ""); + if (next) { + cookies.set("ad_next", next, { + path: "/", + httpOnly: true, + secure: true, + // Lax: the browser returns from Google as a cross-site navigation, and a Strict + // cookie would not be sent on that first request. + sameSite: "lax", + maxAge: 600, // 10 minutes — long enough to sign in, short enough not to linger + }); + } else { + // Clear any stale value so an earlier attempt cannot hijack this sign-in. + cookies.delete("ad_next", { path: "/" }); + } + const res = await apiFetch( { apiBase: locals.apiBase, apiKey: null, service: locals.apiService }, "/api/auth/google/start", diff --git a/dashboard/src/routes/auth/google/callback/+server.ts b/dashboard/src/routes/auth/google/callback/+server.ts index 8e3a843..b736b67 100644 --- a/dashboard/src/routes/auth/google/callback/+server.ts +++ b/dashboard/src/routes/auth/google/callback/+server.ts @@ -1,6 +1,7 @@ import { redirect } from "@sveltejs/kit"; import type { RequestHandler } from "./$types"; import { apiFetch } from "$lib/server/api"; +import { safeNext } from "$lib/server/next"; // GET /auth/google/callback — Google redirects the browser back here with ?code&state. // @@ -37,5 +38,11 @@ export const GET: RequestHandler = async ({ url, locals, cookies }) => { sameSite: "lax", maxAge: 60 * 60 * 24 * 30, }); - throw redirect(303, "/dashboard"); + + // Return the viewer to the document that sent them here. Re-validated on read: the + // cookie is ours and httpOnly, but sanitising only on write means one future code path + // that sets it unchecked becomes an open redirect. + const next = safeNext(cookies.get("ad_next")); + cookies.delete("ad_next", { path: "/" }); + throw redirect(303, next); }; diff --git a/dashboard/src/routes/login/+page.server.ts b/dashboard/src/routes/login/+page.server.ts index 654abc5..bc7c77b 100644 --- a/dashboard/src/routes/login/+page.server.ts +++ b/dashboard/src/routes/login/+page.server.ts @@ -1,12 +1,16 @@ import { fail, redirect } from "@sveltejs/kit"; import type { Actions, PageServerLoad } from "./$types"; import { apiFetch } from "$lib/server/api"; +import { safeNext } from "$lib/server/next"; // Ask the API whether Google OAuth is configured on this deployment. The button is // rendered only when it is genuinely usable — showing a dead sign-in button is worse // than not offering it. export const load: PageServerLoad = async ({ locals, url }) => { const googleError = url.searchParams.get("google"); + // Where to land after sign-in. Validated here rather than at use time so a hostile + // value never reaches the page or a Location header. + const next = safeNext(url.searchParams.get("next")); try { const res = await apiFetch( { apiBase: locals.apiBase, apiKey: null, service: locals.apiService }, @@ -22,16 +26,17 @@ export const load: PageServerLoad = async ({ locals, url }) => { // hosted => Google is the ONLY door; the key-paste form is not rendered at all. hosted: cfg.auth_mode === "hosted", googleError, + next, }; } } catch { /* config is advisory; fall back to key-only sign-in */ } - return { googleEnabled: false, hosted: false, googleError }; + return { googleEnabled: false, hosted: false, googleError, next }; }; export const actions: Actions = { - default: async ({ request, cookies, locals }) => { + default: async ({ request, cookies, locals, url }) => { // Hiding the form is not enforcement — a hand-crafted POST must be refused too. // The API rejects this as well; this is defence in depth at the BFF boundary. try { @@ -70,6 +75,9 @@ export const actions: Actions = { sameSite: "strict", maxAge: 60 * 60 * 24 * 30, }); - throw redirect(303, "/dashboard"); + // Return the viewer to the document they originally asked for. safeNext collapses + // anything hostile or off-origin to /dashboard, so this cannot become an open + // redirect even though the value arrives in a query string. + throw redirect(303, safeNext(url.searchParams.get("next"))); }, }; diff --git a/dashboard/src/routes/login/+page.svelte b/dashboard/src/routes/login/+page.svelte index 9463a56..cda81fb 100644 --- a/dashboard/src/routes/login/+page.svelte +++ b/dashboard/src/routes/login/+page.svelte @@ -12,6 +12,13 @@ const errorText = $derived( data?.googleError ? (errors[data.googleError] ?? `Google sign-in failed (${data.googleError}).`) : null, ); + + // Carry the return-to path onto the Google button and the key-paste form action, so a + // viewer who followed a private draft link lands back on that document after signing + // in rather than on the dashboard. `data.next` is already validated server-side. + const nextQS = $derived( + data?.next && data.next !== "/dashboard" ? `?next=${encodeURIComponent(data.next)}` : "", + ); </script> <Seo title="Sign in — agentdraft" description="Sign in to agentdraft." path="/login" noindex /> @@ -30,7 +37,7 @@ {#if errorText} <p class="error" style="margin-top:0">{errorText}</p> {/if} - <a class="btn" href="/auth/google">Sign in with Google</a> + <a class="btn" href="/auth/google{nextQS}">Sign in with Google</a> <p class="subtle" style="margin:1rem 0 0;font-size:13px"> API keys are machine credentials. They publish drafts but cannot sign in here or create further keys — so a key leaked from a CI config or a dotfile can never be @@ -62,7 +69,7 @@ {#if data?.googleEnabled} <div class="card" style="max-width:30rem"> <h3>Or continue with Google</h3> - <a class="btn btn--ghost" href="/auth/google">Sign in with Google</a> + <a class="btn btn--ghost" href="/auth/google{nextQS}">Sign in with Google</a> </div> {/if} {/if} diff --git a/dashboard/src/routes/private/[...path]/+server.ts b/dashboard/src/routes/private/[...path]/+server.ts new file mode 100644 index 0000000..bc39b57 --- /dev/null +++ b/dashboard/src/routes/private/[...path]/+server.ts @@ -0,0 +1,100 @@ +import { redirect, error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { apiFetch } from "$lib/server/api"; + +// GET /private/d/:id — a PRIVATE draft, streamed to its owner +// GET /private/d/:id/v/:n — a specific version +// GET /private/d/:id/raw — the exact uploaded source +// +// WHY THIS ROUTE EXISTS. +// +// The content origin is cookie-free by design: it serves attacker-controlled HTML, so a +// session must never be readable there. It therefore cannot authorize a viewer, and a +// private draft 302s here instead. This handler runs on the DASHBOARD origin, where the +// session lives, so it can prove who the viewer is and stream the bytes itself. +// +// The user-visible contract is "the shared link just opens when I'm signed in": the URL +// the owner pasted is unchanged, and this hop is invisible in normal browsing. A +// signed-out viewer is sent to sign in and returns to this exact document afterwards. +// +// It deliberately returns the RAW DOCUMENT, not a dashboard page with chrome around it — +// a draft link should feel like a bare document wherever it is opened. + +const DRAFT_PATH = /^d\/([a-z0-9]{6,32})(?:\/v\/(\d+))?(\/raw)?\/?$/; + +export const GET: RequestHandler = async ({ params, locals, url }) => { + const path = params.path ?? ""; + const m = DRAFT_PATH.exec(path); + // Not a draft-shaped path. 404 rather than forwarding an arbitrary string to the API. + if (!m) throw error(404, "Not found"); + + const [, draftId, versionStr, rawSuffix] = m; + + // Not signed in -> sign in, then come back HERE. `next` is validated on the way back + // (see /login) so it can only ever be an internal path, never an open redirect. + if (!locals.sessionId && !locals.apiKey) { + throw redirect(303, `/login?next=${encodeURIComponent(url.pathname)}`); + } + + const qs = new URLSearchParams(); + if (versionStr) qs.set("v", versionStr); + if (rawSuffix) qs.set("raw", "1"); + const suffix = qs.toString() ? `?${qs}` : ""; + + const res = await apiFetch( + { + apiBase: locals.apiBase, + apiKey: locals.apiKey, + service: locals.apiService, + sessionId: locals.sessionId, + }, + `/api/drafts/${draftId}/content${suffix}`, + ); + + if (res.status === 404) { + // The API answers 404 for "no such draft" AND for "not yours" — deliberately + // indistinguishable, so this endpoint cannot be used to enumerate other accounts' + // drafts. Keep that collapse here; do not helpfully differentiate. + throw error(404, "Not found"); + } + if (res.status === 401 || res.status === 403) { + // Session expired or belongs to a different account. Re-authenticate and return. + throw redirect(303, `/login?next=${encodeURIComponent(url.pathname)}`); + } + if (res.status === 451) throw error(451, "This document has been disabled."); + if (!res.ok) throw error(502, "Could not load this document."); + + // Stream the body straight through — never buffer a document that may be up to 2 MiB. + // + // The API already set the hardening headers (CSP with sandbox, nosniff, noindex, + // no-store). They are copied rather than regenerated so there is ONE source of truth: + // if the API tightens its policy, this path inherits it automatically instead of + // drifting into a weaker copy. + const headers = new Headers(); + for (const h of [ + "content-type", + "content-security-policy", + "x-content-type-options", + "x-robots-tag", + "referrer-policy", + "cache-control", + "x-agentdraft-draft-id", + "x-agentdraft-version", + "x-agentdraft-visibility", + ]) { + const v = res.headers.get(h); + if (v) headers.set(h, v); + } + // Belt and braces: if the API ever omitted these, a private document would be served + // from the dashboard origin with no policy at all. + if (!headers.has("cache-control")) headers.set("cache-control", "private, no-store"); + if (!headers.has("x-robots-tag")) headers.set("x-robots-tag", "noindex, nofollow"); + if (!headers.has("content-security-policy")) { + headers.set( + "content-security-policy", + "default-src 'none'; img-src https: data:; style-src 'unsafe-inline'; font-src https: data:; media-src https:; script-src 'none'; connect-src 'none'; form-action 'none'; frame-ancestors 'self'; base-uri 'none'; sandbox allow-popups", + ); + } + + return new Response(res.body, { status: 200, headers }); +}; diff --git a/worker/migrations/0004_private_drafts.sql b/worker/migrations/0004_private_drafts.sql new file mode 100644 index 0000000..5f7693f --- /dev/null +++ b/worker/migrations/0004_private_drafts.sql @@ -0,0 +1,32 @@ +-- Private drafts + higher-entropy draft ids. +-- +-- WHAT PROBLEM THIS SOLVES. Until now every published draft was readable by anyone +-- holding its URL, forever, with no revocation. URLs leak — chat, PR comments, CI logs, +-- Referer headers — and entropy does nothing about a pasted link. Access control does. +-- +-- `drafts.is_public` already existed and the content worker already enforced it +-- (403 when 0), but NOTHING could ever set it. This migration makes it settable and +-- gives accounts a default to apply to new drafts. +-- +-- DELIBERATELY NOT RETROACTIVE. Adding a column with DEFAULT 1 leaves every existing +-- draft public, which is correct: flipping shared links private without warning breaks +-- URLs people already handed to reviewers. The default applies to NEW drafts, and the +-- dashboard/CLI expose an explicit bulk action for existing ones. + +-- Per-account default visibility for NEWLY created drafts. +-- 1 = new drafts are public (today's behaviour, so existing users see no change) +-- 0 = new drafts are private +ALTER TABLE accounts ADD COLUMN default_draft_public INTEGER NOT NULL DEFAULT 1; + +-- When a draft was last made private/public and by whom, for support and audit. A user +-- asking "why can nobody open my link" is almost always a visibility change they forgot. +ALTER TABLE drafts ADD COLUMN visibility_changed_at TEXT; + +-- The content origin is cookie-free by design (it serves attacker-controlled HTML, so a +-- session must never be readable there). A private draft is therefore NOT served by the +-- content worker at all: it redirects to the dashboard, which holds the session, verifies +-- ownership, and streams the bytes itself. +-- +-- This index supports that hot path: the content worker checks visibility on EVERY +-- request before touching R2 or its cache, so the lookup must stay cheap. +CREATE INDEX idx_drafts_visibility ON drafts(id, is_public) WHERE deleted_at IS NULL; diff --git a/worker/src/index.ts b/worker/src/index.ts index 4df9545..625aadd 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -9,7 +9,7 @@ import { requireHumanSession, } from "./middleware/auth.js"; import { rateLimit } from "./middleware/ratelimit.js"; -import { jsonError } from "./lib/http.js"; +import { jsonError, readJsonObject } from "./lib/http.js"; import uploadRoute from "./routes/upload.js"; import bootstrapRoute from "./routes/bootstrap.js"; import sessionRoute from "./routes/session.js"; @@ -80,12 +80,42 @@ app.get("/api/me", requireAuth(), (c) => { name: auth.account.name, email: auth.account.email, is_owner: !!auth.account.is_owner, + // Default visibility for NEW drafts. Surfaced here so both the CLI (`whoami`) and + // the dashboard read one source of truth rather than each keeping a local guess. + default_draft_public: auth.account.default_draft_public !== 0, }, via: auth.via, scopes: auth.scopes, }); }); +// PATCH /api/me/settings — account preferences. +// Body: { default_draft_public: boolean } +// +// Changing this affects FUTURE drafts only. Existing drafts are untouched, because +// silently flipping links already shared with reviewers is the one behaviour a +// visibility preference must never have. The bulk endpoint is the explicit opt-in. +app.patch("/api/me/settings", requireAuth(), requireScope("upload"), async (c) => { + const auth = c.get("auth"); + const parsed = await readJsonObject(c); + if (!parsed.ok) return parsed.response; + if (typeof parsed.body.default_draft_public !== "boolean") { + return jsonError( + c, + 400, + "E_BAD_FIELD", + "Field 'default_draft_public' must be a boolean.", + ); + } + const val = parsed.body.default_draft_public ? 1 : 0; + await c.env.DB.prepare( + "UPDATE accounts SET default_draft_public = ?, updated_at = datetime('now') WHERE id = ?", + ) + .bind(val, auth.account.id) + .run(); + return c.json({ ok: true, default_draft_public: !!val }); +}); + // Read APIs — auth + read scope + generous rate limit (1000/hour). app.use("/api/projects/*", requireAuth(), rateLimit("read", 1000, 3600)); app.use("/api/projects", requireAuth(), rateLimit("read", 1000, 3600)); @@ -93,6 +123,15 @@ app.route("/api/projects", projectsRoute); app.use("/api/drafts/*", requireAuth(), rateLimit("read", 1000, 3600)); app.use("/api/drafts", requireAuth(), rateLimit("read", 1000, 3600)); +// Visibility is a MUTATION, so it needs more than the read scope every draft route +// carries. "upload" is the write capability a machine key already holds, which keeps the +// CLI able to publish privately without granting it account administration ("manage"). +// +// Registered BEFORE app.route so these run as route-specific middleware. Hono matches +// middleware by path, so the bulk route must be listed explicitly — "/api/drafts/*" +// above already covers it for auth, but the scope gate has to name each mutation. +app.patch("/api/drafts/:id/visibility", requireScope("upload")); +app.post("/api/drafts/visibility/bulk", requireScope("upload")); app.route("/api/drafts", draftsRoute); // API key management requires the "manage" scope. This blocks an upload/read key diff --git a/worker/src/routes/drafts.ts b/worker/src/routes/drafts.ts index 3f0ae07..4c7bb3b 100644 --- a/worker/src/routes/drafts.ts +++ b/worker/src/routes/drafts.ts @@ -1,7 +1,8 @@ import { Hono } from "hono"; import type { Env } from "../env.js"; -import type { AuthContext, Draft } from "../types.js"; -import { jsonError } from "../lib/http.js"; +import type { AuthContext, Draft, DraftVersion } from "../types.js"; +import { jsonError, readJsonObject } from "../lib/http.js"; +import { renderedKey } from "../services/storage.js"; const drafts = new Hono<{ Bindings: Env; Variables: { auth: AuthContext } }>(); @@ -52,6 +53,143 @@ drafts.get("/:id", async (c) => { return c.json({ ok: true, draft: { ...draft, ...contentUrls(c.env, draft.id) }, versions: versions.results }); }); +// PATCH /api/drafts/:id/visibility — make one draft public or private. +// Body: { public: boolean } +// +// Ownership is enforced in the WHERE clause, not by a separate read-then-write: a +// check-then-act pair could be raced, and it would also leak existence (a 403 vs 404 +// difference) for a draft the caller does not own. A non-owner gets a plain 404. +drafts.patch("/:id/visibility", async (c) => { + const auth = c.get("auth"); + const id = c.req.param("id"); + const parsed = await readJsonObject(c); + if (!parsed.ok) return parsed.response; + if (typeof parsed.body.public !== "boolean") { + return jsonError(c, 400, "E_BAD_FIELD", "Field 'public' must be a boolean."); + } + const isPublic = parsed.body.public ? 1 : 0; + const res = await c.env.DB.prepare( + "UPDATE drafts SET is_public = ?, visibility_changed_at = datetime('now'), updated_at = datetime('now') WHERE id = ? AND account_id = ? AND deleted_at IS NULL", + ) + .bind(isPublic, id, auth.account.id) + .run(); + // D1 reports rows_written; zero means no such draft FOR THIS ACCOUNT. + if (!res.meta.changes) return jsonError(c, 404, "E_DRAFT_NOT_FOUND", "Draft not found."); + return c.json({ ok: true, id, public: !!isPublic, ...contentUrls(c.env, id) }); +}); + +// POST /api/drafts/visibility/bulk — apply a visibility to EVERY draft in the account. +// Body: { public: boolean } +// +// This is the explicit "also change my existing drafts" action. It is deliberately a +// SEPARATE call from the account default: flipping a preference must never silently +// rewrite the visibility of links already shared with reviewers. The response reports +// how many rows changed so the UI can say exactly what happened. +drafts.post("/visibility/bulk", async (c) => { + const auth = c.get("auth"); + const parsed = await readJsonObject(c); + if (!parsed.ok) return parsed.response; + if (typeof parsed.body.public !== "boolean") { + return jsonError(c, 400, "E_BAD_FIELD", "Field 'public' must be a boolean."); + } + const isPublic = parsed.body.public ? 1 : 0; + const res = await c.env.DB.prepare( + "UPDATE drafts SET is_public = ?, visibility_changed_at = datetime('now'), updated_at = datetime('now') WHERE account_id = ? AND deleted_at IS NULL AND is_public != ?", + ) + .bind(isPublic, auth.account.id, isPublic) + .run(); + return c.json({ ok: true, public: !!isPublic, changed: res.meta.changes ?? 0 }); +}); + +// GET /api/drafts/:id/content — stream a draft's bytes to its OWNER. +// +// WHY THIS EXISTS. The content origin is cookie-free by design (it serves +// attacker-controlled HTML, so a session must never be readable there) and therefore +// cannot authorize anyone. A private draft redirects to the dashboard, and the dashboard +// calls THIS endpoint to fetch the bytes with the viewer's credentials. +// +// Query: ?v=<n> for a specific version, ?raw=1 for the exact uploaded source. +drafts.get("/:id/content", async (c) => { + const auth = c.get("auth"); + const id = c.req.param("id"); + const vParam = c.req.query("v"); + const wantRaw = c.req.query("raw") === "1"; + + const draft = await c.env.DB.prepare( + "SELECT * FROM drafts WHERE id = ? AND account_id = ? AND deleted_at IS NULL", + ) + .bind(id, auth.account.id) + .first<Draft>(); + if (!draft) return jsonError(c, 404, "E_DRAFT_NOT_FOUND", "Draft not found."); + if (draft.disabled_at) { + return jsonError(c, 451, "E_DISABLED", "This document has been disabled."); + } + + // Resolve which version to serve. Guard the parse: `parseInt("abc")` is NaN, which + // would bind as NULL and silently return "not found" for a typo'd version. + let version; + if (vParam !== undefined) { + const n = Number.parseInt(vParam, 10); + if (!Number.isInteger(n) || n < 1) { + return jsonError(c, 400, "E_BAD_VERSION", "Query 'v' must be a positive integer."); + } + version = await c.env.DB.prepare( + "SELECT * FROM draft_versions WHERE draft_id = ? AND version_number = ?", + ) + .bind(id, n) + .first<DraftVersion>(); + } else { + version = await c.env.DB.prepare("SELECT * FROM draft_versions WHERE id = ?") + .bind(draft.current_version_id) + .first<DraftVersion>(); + } + if (!version) return jsonError(c, 404, "E_VERSION_NOT_FOUND", "Version not found."); + + const isMd = version.source_format === "md"; + const key = isMd && !wantRaw ? renderedKey(version.object_key) : version.object_key; + const obj = await c.env.STORAGE.get(key); + if (!obj) return jsonError(c, 404, "E_CONTENT_UNAVAILABLE", "Content unavailable."); + + // Mirror the content worker's CSP so a private draft is no less sandboxed than a + // public one. This response carries the document on the DASHBOARD origin, so the + // policy matters MORE here, not less: without it, untrusted HTML would run in the + // same origin as the session cookie. + // + // sandbox is applied by the embedding page too; this is the defence that survives + // someone opening the URL directly. + return new Response(obj.body, { + status: 200, + headers: { + "Content-Type": + wantRaw && isMd ? "text/markdown; charset=utf-8" : "text/html; charset=utf-8", + "Content-Security-Policy": [ + "default-src 'none'", + "img-src https: data:", + "style-src 'unsafe-inline'", + "font-src https: data:", + "media-src https:", + "script-src 'none'", + "connect-src 'none'", + "form-action 'none'", + "frame-ancestors 'self'", + "base-uri 'none'", + // sandbox in the HEADER, not just the iframe attribute: a direct navigation to + // this URL has no embedding iframe to carry the attribute. + "sandbox allow-popups", + ].join("; "), + "X-Content-Type-Options": "nosniff", + "X-Robots-Tag": "noindex, nofollow", + "Referrer-Policy": "no-referrer", + // Never let a shared cache hold a private document: the next requester may be a + // different principal. + "Cache-Control": "private, no-store", + "X-AgentDraft-Draft-Id": draft.id, + "X-AgentDraft-Version": String(version.version_number), + "X-AgentDraft-Visibility": draft.is_public ? "public" : "private", + }, + }); +}); + // DELETE /api/drafts/:id — soft-delete. drafts.delete("/:id", async (c) => { const auth = c.get("auth"); diff --git a/worker/src/routes/upload.ts b/worker/src/routes/upload.ts index 699a2cc..0255b8c 100644 --- a/worker/src/routes/upload.ts +++ b/worker/src/routes/upload.ts @@ -146,10 +146,25 @@ upload.post("/", async (c) => { if (!draft) { const id = newDraftId(); + // Visibility for a NEW draft comes from the account default, unless this request + // names one explicitly. Read it per-upload rather than caching: the user may flip + // the default in the dashboard between two agent uploads, and the next upload must + // honour the CURRENT setting. + // + // Explicit beats default so a single upload can opt out either way + // (`--private` / `--public`) without touching the account preference. + const acct = await c.env.DB.prepare( + "SELECT default_draft_public FROM accounts WHERE id = ?", + ) + .bind(auth.account.id) + .first<{ default_draft_public: number }>(); + const defaultPublic = acct?.default_draft_public ?? 1; + const isPublic = + typeof body.public === "boolean" ? (body.public ? 1 : 0) : defaultPublic; await c.env.DB.prepare( - "INSERT INTO drafts (id, project_id, account_id, title, description, last_allocated_version) VALUES (?, ?, ?, ?, ?, 0)", + "INSERT INTO drafts (id, project_id, account_id, title, description, last_allocated_version, is_public, visibility_changed_at) VALUES (?, ?, ?, ?, ?, 0, ?, CASE WHEN ? = 0 THEN datetime('now') ELSE NULL END)", ) - .bind(id, projectId, auth.account.id, title, description) + .bind(id, projectId, auth.account.id, title, description, isPublic, isPublic) .run(); draft = await c.env.DB.prepare("SELECT * FROM drafts WHERE id = ?") .bind(id) diff --git a/worker/src/services/id.ts b/worker/src/services/id.ts index e988700..32478d0 100644 --- a/worker/src/services/id.ts +++ b/worker/src/services/id.ts @@ -9,8 +9,18 @@ export function newId(prefix: string, len = 16): string { } // Draft IDs have no prefix and appear directly in the public URL. +// +// 22 chars of base36 is ~113 bits. The previous 12 chars was ~62 bits, which was already +// not enumerable in practice (at 100k req/s against 100k drafts an attacker averages +// ~7.5 years to a first hit, before any rate limiting). This is therefore defence in +// depth, NOT the privacy mechanism — a leaked URL is readable at any entropy, so real +// access control (is_public + owner check) is what actually makes a draft private. +// +// EXISTING 12-CHAR IDS KEEP WORKING and are NOT migrated: people have already shared +// those links. The content worker's route regex accepts 6..32 chars, so old and new +// coexist permanently. Do not "clean this up" by rewriting old ids. export function newDraftId(): string { - return gen().slice(0, 12); + return gen().slice(0, 22); } // A full API key: "ad_" + 40 chars of high-entropy base36. The visible prefix used diff --git a/worker/src/types.ts b/worker/src/types.ts index 2673f08..cf07f1c 100644 --- a/worker/src/types.ts +++ b/worker/src/types.ts @@ -5,6 +5,9 @@ export interface Account { avatar_url: string | null; google_sub: string | null; is_owner: number; + // Default visibility applied to NEWLY created drafts (1 = public). Existing drafts are + // never affected by changing this — see migration 0004. + default_draft_public: number; created_at: string; updated_at: string; } From cdd08592ecf4561667ff67e783163fc93637c92f Mon Sep 17 00:00:00 2001 From: Hitesh Sisara <hitesh.dev@sisara.in> Date: Sun, 9 Aug 2026 03:49:47 +0000 Subject: [PATCH 7/9] feat(cli): --private/--public, visibility command, private fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the CLI half of private drafts. Backend shipped in 3b946b0 but nothing in the CLI could reach it. agentdraft upload plan.md --private create it owner-only agentdraft visibility private plan.md by file, id, or url agentdraft visibility public --all every existing draft agentdraft visibility private --default default for NEW drafts only agentdraft list now carries a VISIBILITY column agentdraft whoami shows what the next upload will be TWO DEFECTS FOUND WHILE WIRING THIS, both in existing code: 1. `fetch` passed an EMPTY api key, so the new private-draft fallback could never have authenticated. It now loads stored credentials, while still working unauthenticated for public drafts. 2. `fetch` followed redirects blindly. A private draft 302s to the dashboard sign-in page, so fetch would have written an HTML LOGIN PAGE to the output file and exited 0 — a silent wrong-content success, the worst failure shape available. It now refuses to follow, detects the hop, and retries through the authenticated API endpoint. UploadRequest.Public is a *bool, not a bool: with omitempty a plain false is dropped from the JSON, which would publish a draft the user asked to keep private. The pointer keeps "unspecified" (use the account default) distinct from "explicitly public". Re-uploading never changes an existing draft's visibility. Silently flipping a shared link on the next upload would be a nasty surprise; that is what the visibility command is for. VISIBILITY column added to list because a private URL is byte-identical to a public one — without it the only way to tell them apart is opening the link in a logged-out browser, which is the mistake the column prevents. Tested against live prod: --private upload then anonymous read = 302 with zero content bytes; owner fetch returns the real document (marker present, no login-page words); fetch with NO credentials exits 1 with a clear message and writes 0 bytes rather than a login page; visibility resolves by id, file path, and full url; bad verb, missing target, and --all/--default together all rejected. Added draftPathRe unit tests covering legacy 12-char and current 22-char ids, MUTATION TESTED: loosening the floor to {1,32} fails the reject suite immediately, so the guard is proven rather than assumed. --- cli/cmd/fetch.go | 12 ++- cli/cmd/list.go | 11 ++- cli/cmd/upload.go | 22 +++++ cli/cmd/visibility.go | 150 +++++++++++++++++++++++++++++ cli/cmd/whoami.go | 7 ++ cli/internal/api/client.go | 134 +++++++++++++++++++++++++- cli/internal/api/draftpath_test.go | 62 ++++++++++++ cli/internal/api/types.go | 31 ++++++ 8 files changed, 425 insertions(+), 4 deletions(-) create mode 100644 cli/cmd/visibility.go create mode 100644 cli/internal/api/draftpath_test.go diff --git a/cli/cmd/fetch.go b/cli/cmd/fetch.go index 65bab8a..6c40940 100644 --- a/cli/cmd/fetch.go +++ b/cli/cmd/fetch.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/Tapetide-hq/agentdraft/cli/internal/api" + "github.com/Tapetide-hq/agentdraft/cli/internal/auth" "github.com/Tapetide-hq/agentdraft/cli/internal/config" ) @@ -26,7 +27,16 @@ var fetchCmd = &cobra.Command{ if err != nil { return err } - client := api.New(cfg.APIURL, "") + // Pass the stored key when there is one. Public drafts do not need it, but a + // PRIVATE draft answers the content origin with a 302 and the client falls back + // to the authenticated API path — which is impossible with an empty key. Missing + // credentials are not an error here: fetching a public draft must keep working + // for an unauthenticated user. + key := "" + if creds, err := auth.Load(); err == nil { + key = creds.APIKey + } + client := api.New(cfg.APIURL, key) target := args[0] rawURL := resolveFetchURL(target, fetchVersion) diff --git a/cli/cmd/list.go b/cli/cmd/list.go index 85fa635..54bdbea 100644 --- a/cli/cmd/list.go +++ b/cli/cmd/list.go @@ -41,9 +41,16 @@ var listCmd = &cobra.Command{ return nil } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "DRAFT ID\tVER\tTITLE\tURL") + // VISIBILITY is shown because a private draft's URL looks identical to a public + // one. Without this column the only way to tell them apart is to open the link + // in a logged-out browser, which is exactly the mistake this column prevents. + fmt.Fprintln(w, "DRAFT ID VER VISIBILITY TITLE URL") for _, d := range resp.Drafts { - fmt.Fprintf(w, "%s\t%d\t%s\t%s\n", d.ID, d.PublishedVersion, truncate(d.Title, 30), d.PublicURL) + vis := "public" + if d.IsPublic == 0 { + vis = "private" + } + fmt.Fprintf(w, "%s %d %s %s %s\n", d.ID, d.PublishedVersion, vis, truncate(d.Title, 30), d.PublicURL) } return w.Flush() }, diff --git a/cli/cmd/upload.go b/cli/cmd/upload.go index 858dbe8..d7fc972 100644 --- a/cli/cmd/upload.go +++ b/cli/cmd/upload.go @@ -24,6 +24,8 @@ var ( upDescription string upTitle string upIdempotency string + upPrivate bool + upPublic bool ) var uploadCmd = &cobra.Command{ @@ -97,6 +99,21 @@ var uploadCmd = &cobra.Command{ }, } + // Visibility override for a NEW draft. Sent as a POINTER so "flag absent" is + // distinguishable from "explicitly public": omitting it lets the account default + // apply, which is what an agent with no opinion should get. + // + // Note this only affects draft CREATION. Re-uploading to an existing draft never + // changes its visibility — silently flipping a shared link on the next `upload` + // would be a nasty surprise. Use `agentdraft visibility` for that. + if upPrivate { + v := false + req.Public = &v + } else if upPublic { + v := true + req.Public = &v + } + resp, err := client.Upload(req, upIdempotency) if err != nil { return err @@ -164,5 +181,10 @@ func init() { uploadCmd.Flags().StringVar(&upDescription, "description", "", "short label for the draft") uploadCmd.Flags().StringVar(&upTitle, "title", "", "override the extracted title") uploadCmd.Flags().StringVar(&upIdempotency, "idempotency-key", "", "dedupe retries") + uploadCmd.Flags().BoolVar(&upPrivate, "private", false, "create the draft private (owner-only); overrides the account default") + uploadCmd.Flags().BoolVar(&upPublic, "public", false, "create the draft public; overrides the account default") + // Mutually exclusive: asking for both is a mistake worth surfacing rather than + // silently letting one win. + uploadCmd.MarkFlagsMutuallyExclusive("private", "public") rootCmd.AddCommand(uploadCmd) } diff --git a/cli/cmd/visibility.go b/cli/cmd/visibility.go new file mode 100644 index 0000000..6ae3b0f --- /dev/null +++ b/cli/cmd/visibility.go @@ -0,0 +1,150 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + + "github.com/spf13/cobra" + + "github.com/Tapetide-hq/agentdraft/cli/internal/api" + "github.com/Tapetide-hq/agentdraft/cli/internal/auth" + "github.com/Tapetide-hq/agentdraft/cli/internal/config" + "github.com/Tapetide-hq/agentdraft/cli/internal/drafts" +) + +var ( + visAll bool + visDefault bool +) + +// contentPathRe pulls a draft id out of a full content URL. +// 6..32 chars so both legacy 12-char ids and current 22-char ids resolve. +var contentPathRe = regexp.MustCompile(`/d/([a-z0-9]{6,32})`) + +// absOrSelf resolves a path for the drafts.json lookup, which is keyed on ABSOLUTE +// paths. Falls back to the input unchanged when it is not a usable path, so a bare id +// or URL passes through untouched instead of erroring. +func absOrSelf(p string) string { + abs, err := filepath.Abs(p) + if err != nil { + return p + } + return abs +} + +// resolveDraftArg turns a user-supplied argument into a draft id. +// +// Accepts a bare id, a full content URL, or a local file path that has been uploaded +// before (resolved through the same ~/.agentdraft/drafts.json mapping `upload` uses). +// Accepting the file path matters: the user's mental model is "the plan I published", +// not a 22-char opaque id they never chose. +func resolveDraftArg(arg string) string { + if id := drafts.Get(absOrSelf(arg)); id != "" { + return id + } + if m := contentPathRe.FindStringSubmatch(arg); m != nil { + return m[1] + } + return arg +} + +var visibilityCmd = &cobra.Command{ + Use: "visibility <public|private> [draft-id|url|file]", + Short: "Make a draft public or private", + Long: `Change who can read a draft. + + public anyone with the URL can read it + private only you, signed in via the dashboard or this CLI + +The URL never changes. A private draft's link still works for you — it redirects +through the dashboard, which verifies you own it. Anyone else gets a sign-in page. + + agentdraft visibility private plan.md # the draft this file publishes to + agentdraft visibility private a1b2c3d4e5f6 # by id + agentdraft visibility public --all # every draft in the account + agentdraft visibility private --default # default for NEW drafts only`, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + var public bool + switch args[0] { + case "public": + public = true + case "private": + public = false + default: + return fmt.Errorf("first argument must be 'public' or 'private', got %q", args[0]) + } + + cfg, err := config.Load() + if err != nil { + return err + } + creds, err := auth.Load() + if err != nil { + return err + } + client := api.New(cfg.APIURL, creds.APIKey) + + switch { + case visDefault: + // Account default. Deliberately does NOT touch existing drafts — see --all. + res, err := client.SetDefaultVisibility(public) + if err != nil { + return err + } + state := "private" + if res.DefaultDraftPublic { + state = "public" + } + fmt.Printf("New drafts will be %s.\n", state) + fmt.Println("Existing drafts are unchanged. Use --all to change those too.") + return nil + + case visAll: + res, err := client.SetAllDraftsVisibility(public) + if err != nil { + return err + } + state := "private" + if res.Public { + state = "public" + } + // Report the real count. "Done" without a number hides a no-op. + switch res.Changed { + case 0: + fmt.Printf("No changes — every draft was already %s.\n", state) + case 1: + fmt.Printf("1 draft is now %s.\n", state) + default: + fmt.Printf("%d drafts are now %s.\n", res.Changed, state) + } + return nil + + default: + if len(args) < 2 { + return fmt.Errorf("specify a draft (id, url, or file), or pass --all / --default") + } + id := resolveDraftArg(args[1]) + res, err := client.SetDraftVisibility(id, public) + if err != nil { + return err + } + if res.Public { + fmt.Printf("Public — anyone with the link can read it.\n%s\n", res.PublicURL) + } else { + fmt.Printf("Private — only you can read it, signed in.\n%s\n", res.PublicURL) + fmt.Fprintln(os.Stderr, "The link is unchanged; others now get a sign-in page.") + } + return nil + } + }, +} + +func init() { + visibilityCmd.Flags().BoolVar(&visAll, "all", false, "apply to EVERY existing draft in the account") + visibilityCmd.Flags().BoolVar(&visDefault, "default", false, "set the default for NEW drafts (existing drafts untouched)") + visibilityCmd.MarkFlagsMutuallyExclusive("all", "default") + rootCmd.AddCommand(visibilityCmd) +} diff --git a/cli/cmd/whoami.go b/cli/cmd/whoami.go index 7a30f2c..299645c 100644 --- a/cli/cmd/whoami.go +++ b/cli/cmd/whoami.go @@ -26,6 +26,13 @@ var whoamiCmd = &cobra.Command{ } fmt.Printf("Auth: %s\n", me.Via) fmt.Printf("Scopes: %s\n", strings.Join(me.Scopes, ", ")) + // Surface the visibility default so a user can see, without guessing, whether + // their next `upload` will produce a public or a private link. + if me.Account.DefaultDraftPublic { + fmt.Printf("New drafts: public\n") + } else { + fmt.Printf("New drafts: private (only you can read them)\n") + } return nil }, } diff --git a/cli/internal/api/client.go b/cli/internal/api/client.go index 53e913b..3497dc2 100644 --- a/cli/internal/api/client.go +++ b/cli/internal/api/client.go @@ -8,10 +8,15 @@ import ( "io" "net/http" "net/url" + "regexp" "strings" "time" ) +// draftPathRe matches a content-origin document path: /d/:id[/v/N][/raw]. +// Accepts 6..32 chars so legacy 12-char ids and current 22-char ids both resolve. +var draftPathRe = regexp.MustCompile(`^/d/([a-z0-9]{6,32})(?:/v/(\d+))?(/raw)?$`) + type Client struct { BaseURL string APIKey string @@ -170,13 +175,104 @@ func (c *Client) CreateProject(name string) (string, error) { return r.Project.ID, nil } +// SetDraftVisibility makes ONE draft public or private. +func (c *Client) SetDraftVisibility(draftID string, public bool) (*VisibilityResponse, error) { + data, status, err := c.do( + http.MethodPatch, + "/api/drafts/"+draftID+"/visibility", + map[string]bool{"public": public}, + nil, + ) + if err != nil { + return nil, err + } + if status == http.StatusNotFound { + // "no such draft" and "not yours" are deliberately indistinguishable server-side. + return nil, fmt.Errorf("draft not found, or it belongs to another account") + } + if status != http.StatusOK { + return nil, parseError(status, data) + } + var r VisibilityResponse + if err := json.Unmarshal(data, &r); err != nil { + return nil, err + } + return &r, nil +} + +// SetAllDraftsVisibility applies a visibility to EVERY draft in the account and reports +// how many rows actually changed. +func (c *Client) SetAllDraftsVisibility(public bool) (*BulkVisibilityResponse, error) { + data, status, err := c.do( + http.MethodPost, + "/api/drafts/visibility/bulk", + map[string]bool{"public": public}, + nil, + ) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, parseError(status, data) + } + var r BulkVisibilityResponse + if err := json.Unmarshal(data, &r); err != nil { + return nil, err + } + return &r, nil +} + +// SetDefaultVisibility changes the account default applied to NEWLY created drafts. +// Existing drafts are untouched — see SetAllDraftsVisibility for that. +func (c *Client) SetDefaultVisibility(public bool) (*SettingsResponse, error) { + data, status, err := c.do( + http.MethodPatch, + "/api/me/settings", + map[string]bool{"default_draft_public": public}, + nil, + ) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, parseError(status, data) + } + var r SettingsResponse + if err := json.Unmarshal(data, &r); err != nil { + return nil, err + } + return &r, nil +} + // Fetch downloads served HTML from a public content URL (no auth). func (c *Client) Fetch(rawURL string) ([]byte, error) { - resp, err := c.http.Get(rawURL) + // Do NOT follow redirects blindly. A PRIVATE draft answers with a 302 to the + // dashboard sign-in page, and following it would hand back an HTML login page as if + // it were the document — the worst possible outcome, because it looks like success. + // Detect the hop and fall back to the authenticated API path instead. + noRedirect := &http.Client{ + Timeout: c.http.Timeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := noRedirect.Get(rawURL) if err != nil { return nil, err } defer resp.Body.Close() + + // 302/403 here means "not public". Retry through the authenticated endpoint, which + // succeeds when the caller owns the draft and 404s when they do not. + if resp.StatusCode == http.StatusFound || + resp.StatusCode == http.StatusMovedPermanently || + resp.StatusCode == http.StatusForbidden { + if c.APIKey == "" { + return nil, fmt.Errorf("this draft is private; run `agentdraft auth set <key>` first") + } + return c.fetchPrivate(rawURL) + } + data, err := io.ReadAll(resp.Body) if err != nil { return nil, err @@ -186,3 +282,39 @@ func (c *Client) Fetch(rawURL string) ([]byte, error) { } return data, nil } + +// fetchPrivate pulls a private draft through the authenticated API, translating a +// content-origin URL (/d/:id[/v/N][/raw]) into the API's owner-only content endpoint. +func (c *Client) fetchPrivate(rawURL string) ([]byte, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + m := draftPathRe.FindStringSubmatch(strings.TrimRight(u.Path, "/")) + if m == nil { + return nil, fmt.Errorf("cannot resolve a draft id from %s", rawURL) + } + q := url.Values{} + if m[2] != "" { + q.Set("v", m[2]) + } + if m[3] != "" { + q.Set("raw", "1") + } + path := "/api/drafts/" + m[1] + "/content" + if len(q) > 0 { + path += "?" + q.Encode() + } + data, status, err := c.do(http.MethodGet, path, nil, nil) + if err != nil { + return nil, err + } + if status == http.StatusNotFound { + // The API deliberately does not distinguish "no such draft" from "not yours". + return nil, fmt.Errorf("draft not found, or it belongs to another account") + } + if status != http.StatusOK { + return nil, parseError(status, data) + } + return data, nil +} diff --git a/cli/internal/api/draftpath_test.go b/cli/internal/api/draftpath_test.go new file mode 100644 index 0000000..fb29fa7 --- /dev/null +++ b/cli/internal/api/draftpath_test.go @@ -0,0 +1,62 @@ +package api + +import "testing" + +// draftPathRe drives the PRIVATE-draft fallback: when the content origin answers 302, +// Fetch translates the content URL into the authenticated API path. If this regex stops +// matching, `agentdraft fetch` on a private draft fails with "cannot resolve a draft id" +// instead of returning the document — and if it matches too loosely, we'd send a +// nonsense id to the API. Neither is caught by a compile. +func TestDraftPathRe(t *testing.T) { + cases := []struct { + path string + wantID string + wantVer string + wantRaw string + }{ + // Current 22-char ids. + {"/d/lrf7131nb6mszmtsbb75bb", "lrf7131nb6mszmtsbb75bb", "", ""}, + {"/d/lrf7131nb6mszmtsbb75bb/raw", "lrf7131nb6mszmtsbb75bb", "", "/raw"}, + {"/d/lrf7131nb6mszmtsbb75bb/v/2", "lrf7131nb6mszmtsbb75bb", "2", ""}, + {"/d/lrf7131nb6mszmtsbb75bb/v/2/raw", "lrf7131nb6mszmtsbb75bb", "2", "/raw"}, + // LEGACY 12-char ids must keep resolving — those links are already shared. + {"/d/cas3z3e5k05r", "cas3z3e5k05r", "", ""}, + {"/d/cas3z3e5k05r/v/10/raw", "cas3z3e5k05r", "10", "/raw"}, + } + for _, c := range cases { + m := draftPathRe.FindStringSubmatch(c.path) + if m == nil { + t.Errorf("%s: expected a match, got none", c.path) + continue + } + if m[1] != c.wantID { + t.Errorf("%s: id = %q, want %q", c.path, m[1], c.wantID) + } + if m[2] != c.wantVer { + t.Errorf("%s: version = %q, want %q", c.path, m[2], c.wantVer) + } + if m[3] != c.wantRaw { + t.Errorf("%s: raw = %q, want %q", c.path, m[3], c.wantRaw) + } + } +} + +func TestDraftPathReRejects(t *testing.T) { + // Each of these must NOT match, so we never hand a bogus id to the API. + bad := []string{ + "/d/", // no id + "/d/short", // under the 6-char floor + "/d/AAAAAAAAAAAA", // uppercase is not in the id alphabet + "/d/abc123/v/", // version marker with no number + "/d/abc123/v/x", // non-numeric version + "/d/abc123/extra", // unknown trailing segment + "/private/d/abc123def456", // dashboard path, not a content path + "/d/abc123def456/raw/extra", // trailing junk after /raw + "/d/" + string(make([]byte, 40)), // over the 32-char ceiling + } + for _, p := range bad { + if m := draftPathRe.FindStringSubmatch(p); m != nil { + t.Errorf("%q: expected NO match, got id=%q", p, m[1]) + } + } +} diff --git a/cli/internal/api/types.go b/cli/internal/api/types.go index 3cd59a5..efe6b64 100644 --- a/cli/internal/api/types.go +++ b/cli/internal/api/types.go @@ -15,6 +15,11 @@ type UploadRequest struct { DraftID string `json:"draft_id,omitempty"` Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` + // Public overrides the account default for a NEW draft. A pointer so "not + // specified" is distinguishable from "explicitly false" — with a plain bool, + // omitempty would drop `false` and silently publish a draft the user asked to keep + // private, which is the one failure mode this field must not have. + Public *bool `json:"public,omitempty"` Metadata *UploadMetadata `json:"metadata,omitempty"` } @@ -43,6 +48,8 @@ type Account struct { Name string `json:"name"` Email string `json:"email"` IsOwner bool `json:"is_owner"` + // DefaultDraftPublic is the account-level default applied to NEWLY created drafts. + DefaultDraftPublic bool `json:"default_draft_public"` } type MeResponse struct { @@ -60,6 +67,30 @@ type Draft struct { PublishedVersion int `json:"published_version"` PublicURL string `json:"public_url"` UpdatedAt string `json:"updated_at"` + // IsPublic is an int in D1 (0/1), so it decodes as a number here rather than a bool. + IsPublic int `json:"is_public"` +} + +// VisibilityResponse is returned by the per-draft visibility endpoint. +type VisibilityResponse struct { + OK bool `json:"ok"` + ID string `json:"id"` + Public bool `json:"public"` + PublicURL string `json:"public_url"` +} + +// BulkVisibilityResponse reports how many drafts a bulk change actually touched, so the +// CLI can tell the user the real number instead of claiming success blindly. +type BulkVisibilityResponse struct { + OK bool `json:"ok"` + Public bool `json:"public"` + Changed int `json:"changed"` +} + +// SettingsResponse is returned by the account-settings endpoint. +type SettingsResponse struct { + OK bool `json:"ok"` + DefaultDraftPublic bool `json:"default_draft_public"` } type DraftsResponse struct { From 206dac0f3a4ebaddac99596e0280ac88dd695639 Mon Sep 17 00:00:00 2001 From: Hitesh Sisara <hitesh.dev@sisara.in> Date: Sun, 9 Aug 2026 04:24:15 +0000 Subject: [PATCH 8/9] feat(dashboard): visibility toggle per draft + privacy settings page Completes private drafts. Backend landed in 3b946b0 and the CLI in cdd0859; until now nothing in the dashboard could change visibility. Per-draft toggle sits directly under the title on the draft detail page, because visibility decides who can read the document and belongs next to its identity rather than buried in settings. New /settings/privacy page carries the account default plus the explicit bulk action, linked from the nav. DESIGN POINTS WORTH KEEPING: The DESIRED state is submitted in a hidden field, never a toggle computed from the rendered value. A toggle derived from stale page data flips the WRONG way when two tabs are open or when the value changed via the CLI since the page loaded. Both actions return without redirecting, so SvelteKit re-runs load() and the page re-reads the authoritative value from the API instead of trusting what was just sent. Bulk confirmation names the REAL count ("Make 3 public drafts private?") and the buttons disable at zero. A generic "are you sure?" is easy to click past; the number is what makes the consequence land. The count comes from the API, and its response reports rows actually changed so a no-op says so rather than claiming success. DEFECT FOUND BY EXERCISING THE ACTION, NOT THE PAGE: I first named the settings action `default`. SvelteKit RESERVES that name for the unnamed default action and rejects an explicit ?/default with a 500 -- so the button was dead. Rendering the page returned a healthy 200 the whole time and svelte-check was clean, so nothing short of POSTing the form surfaced it. Renamed to setdefault. Verified against live prod by driving each form action: setdefault persists and /api/me confirms it; the page then re-renders with the new label; bulk private moves 3 -> 0 public and bulk public restores it; per-draft toggle flips cas3z3e5k05r and anonymous reads go 200 -> 302 -> 200 across the change. Rendered counts matched the API exactly (3 public / 0 private). Account default and all draft visibilities restored to their original state afterwards. --- dashboard/src/routes/+layout.svelte | 3 +- .../src/routes/drafts/[id]/+page.server.ts | 22 +++ dashboard/src/routes/drafts/[id]/+page.svelte | 30 +++++ .../routes/settings/privacy/+page.server.ts | 80 +++++++++++ .../src/routes/settings/privacy/+page.svelte | 127 ++++++++++++++++++ 5 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 dashboard/src/routes/settings/privacy/+page.server.ts create mode 100644 dashboard/src/routes/settings/privacy/+page.svelte diff --git a/dashboard/src/routes/+layout.svelte b/dashboard/src/routes/+layout.svelte index 445e62b..06044e4 100644 --- a/dashboard/src/routes/+layout.svelte +++ b/dashboard/src/routes/+layout.svelte @@ -25,7 +25,8 @@ {#if data.authed} <a href="/dashboard" class:active={path === "/dashboard"}>Drafts</a> <a href="/projects" class:active={path.startsWith("/projects")}>Projects</a> - <a href="/settings/keys" class:active={path.startsWith("/settings")}>Keys</a> + <a href="/settings/keys" class:active={path.startsWith("/settings/keys")}>Keys</a> + <a href="/settings/privacy" class:active={path.startsWith("/settings/privacy")}>Privacy</a> <span class="spacer"></span> <form method="POST" action="/logout"> <button class="btn btn--ghost" type="submit">Sign out</button> diff --git a/dashboard/src/routes/drafts/[id]/+page.server.ts b/dashboard/src/routes/drafts/[id]/+page.server.ts index 16d65e7..de1b95b 100644 --- a/dashboard/src/routes/drafts/[id]/+page.server.ts +++ b/dashboard/src/routes/drafts/[id]/+page.server.ts @@ -24,6 +24,9 @@ interface Draft { published_version: number | null; public_url: string; project_id: string | null; + // 0/1 from D1, not a boolean. + is_public: number; + visibility_changed_at: string | null; } export const load: PageServerLoad = async ({ locals, params }) => { @@ -46,4 +49,23 @@ export const actions: Actions = { if (!res.ok) throw error(res.status, "Failed to delete draft."); throw redirect(303, "/dashboard"); }, + + // Flip THIS draft between public and private. + // + // The desired state is sent explicitly rather than toggled server-side from the + // current value: a toggle computed from stale page data flips the wrong way when two + // tabs are open, or when the value changed via the CLI since this page loaded. + visibility: async ({ locals, params, request }) => { + const ctx = requireKey(locals); + const form = await request.formData(); + const wantPublic = form.get("public") === "true"; + const res = await apiFetch(ctx, `/api/drafts/${params.id}/visibility`, { + method: "PATCH", + body: JSON.stringify({ public: wantPublic }), + }); + if (!res.ok) throw error(res.status, "Failed to change visibility."); + // No redirect: SvelteKit re-runs `load` after an action, so the page re-reads the + // authoritative value from the API instead of trusting what we just sent. + return { visibilityChanged: true, nowPublic: wantPublic }; + }, }; diff --git a/dashboard/src/routes/drafts/[id]/+page.svelte b/dashboard/src/routes/drafts/[id]/+page.svelte index 00567f3..0b3924c 100644 --- a/dashboard/src/routes/drafts/[id]/+page.svelte +++ b/dashboard/src/routes/drafts/[id]/+page.svelte @@ -19,6 +19,10 @@ // Preview the selected version from the CONTENT origin in a sandboxed iframe. const previewSrc = $derived(`${data.contentBase}/d/${draft.id}/v/${activeVersion}`); const rawSrc = $derived(`${data.contentBase}/d/${draft.id}/v/${activeVersion}/raw`); + + // Read visibility from the SERVER value on every render, never from local state: the + // draft may have been flipped from the CLI or another tab since this page loaded. + const isPublic = $derived(draft.is_public !== 0); </script> <Seo title={`${draft.title || "Untitled"} — agentdraft`} description="Draft version history and preview." path={`/drafts/${draft.id}`} noindex /> @@ -33,6 +37,32 @@ {draft.id}{#if draft.description} — {draft.description}{/if} </p> +<!-- VISIBILITY. Placed directly under the title because it changes who can read the + document, which is the single most consequential property on this page. --> +<div class="panel" style="margin-bottom:1.25rem"> + <div class="panel__head">visibility — {isPublic ? "public" : "private"}</div> + <div class="panel__body"> + <div class="row" style="justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem"> + <p class="subtle" style="margin:0;max-width:34rem;font-size:13px"> + {#if isPublic} + Anyone with the link can read this draft. The link works without signing in. + {:else} + Only you can read this draft. The link is unchanged — anyone else who opens it + gets a sign-in page. + {/if} + </p> + <form method="POST" action="?/visibility"> + <!-- The DESIRED state is submitted, not a toggle: a toggle derived from stale + page data flips the wrong way if the value changed elsewhere meanwhile. --> + <input type="hidden" name="public" value={isPublic ? "false" : "true"} /> + <button class="btn btn--ghost" type="submit"> + {isPublic ? "Make private" : "Make public"} + </button> + </form> + </div> + </div> +</div> + <div class="panel panel--accent"> <div class="panel__head">preview — v{activeVersion}{isMd ? " (rendered markdown)" : ""}</div> <div class="panel__body"> diff --git a/dashboard/src/routes/settings/privacy/+page.server.ts b/dashboard/src/routes/settings/privacy/+page.server.ts new file mode 100644 index 0000000..54244e4 --- /dev/null +++ b/dashboard/src/routes/settings/privacy/+page.server.ts @@ -0,0 +1,80 @@ +import type { PageServerLoad, Actions } from "./$types"; +import { apiJson, apiFetch } from "$lib/server/api"; +import { requireKey } from "$lib/server/guard"; +import { fail } from "@sveltejs/kit"; + +interface MeResponse { + account: { id: string; name: string; email: string | null; default_draft_public: boolean }; +} + +interface DraftRow { + id: string; + title: string; + is_public: number; +} + +export const load: PageServerLoad = async ({ locals }) => { + const ctx = requireKey(locals); + const me = await apiJson<MeResponse>(ctx, "/api/me"); + + // Count how many existing drafts are public vs private, so the bulk action can state a + // real number instead of an abstract warning. "Make 47 drafts private" is a decision a + // user can actually make; "make all drafts private" is a gamble. + let publicCount = 0; + let privateCount = 0; + try { + const list = await apiJson<{ drafts: DraftRow[] }>(ctx, "/api/drafts?limit=500"); + for (const d of list.drafts) { + if (d.is_public === 0) privateCount++; + else publicCount++; + } + } catch { + // Counts are advisory. A failure here must not break the settings page — the toggle + // and the bulk action still work without them. + } + + return { + defaultPublic: me.account.default_draft_public, + publicCount, + privateCount, + }; +}; + +export const actions: Actions = { + // Change the default applied to NEWLY created drafts. Existing drafts untouched. + // + // NOT named `default`: SvelteKit reserves that for the unnamed default action and + // rejects an explicit `?/default` with a 500. Naming it that broke the button in the + // UI as well as direct POSTs — caught by exercising the form action rather than only + // rendering the page, which returned a healthy 200 throughout. + setdefault: async ({ request, locals }) => { + const ctx = requireKey(locals); + const form = await request.formData(); + const wantPublic = form.get("public") === "true"; + const res = await apiFetch(ctx, "/api/me/settings", { + method: "PATCH", + body: JSON.stringify({ default_draft_public: wantPublic }), + }); + if (!res.ok) return fail(res.status, { message: "Could not change the default." }); + return { defaultChanged: true, nowPublic: wantPublic }; + }, + + // Apply a visibility to EVERY existing draft. + // + // Deliberately separate from the default toggle: changing a preference must never + // silently rewrite links already shared with reviewers. The user confirms this + // explicitly, and the response reports how many rows actually changed so the UI can + // say what really happened rather than assuming success. + bulk: async ({ request, locals }) => { + const ctx = requireKey(locals); + const form = await request.formData(); + const wantPublic = form.get("public") === "true"; + const res = await apiFetch(ctx, "/api/drafts/visibility/bulk", { + method: "POST", + body: JSON.stringify({ public: wantPublic }), + }); + if (!res.ok) return fail(res.status, { message: "Bulk change failed." }); + const data = (await res.json()) as { changed?: number }; + return { bulkChanged: data.changed ?? 0, nowPublic: wantPublic }; + }, +}; diff --git a/dashboard/src/routes/settings/privacy/+page.svelte b/dashboard/src/routes/settings/privacy/+page.svelte new file mode 100644 index 0000000..1d2a3c9 --- /dev/null +++ b/dashboard/src/routes/settings/privacy/+page.svelte @@ -0,0 +1,127 @@ +<script lang="ts"> + import Seo from "$lib/Seo.svelte"; + let { data, form } = $props(); + + // Read from the server value each render. After an action SvelteKit re-runs `load`, so + // this reflects the authoritative state rather than what we optimistically sent. + const defaultPublic = $derived(data.defaultPublic); + const total = $derived(data.publicCount + data.privateCount); +</script> + +<Seo title="Privacy — agentdraft" description="Draft visibility settings." path="/settings/privacy" noindex /> + +<h1>PRIVACY<span class="accent">.</span></h1> + +{#if form?.message} + <p class="error">{form.message}</p> +{/if} + +<!-- DEFAULT FOR NEW DRAFTS --> +<div class="panel panel--accent"> + <div class="panel__head">new drafts — {defaultPublic ? "public" : "private"}</div> + <div class="panel__body"> + <p class="subtle" style="margin-top:0;max-width:38rem;font-size:13px"> + {#if defaultPublic} + Drafts you publish are readable by anyone with the link. Agents publishing from CI + will produce public URLs. + {:else} + Drafts you publish are readable only by you. The link still works for you — anyone + else who opens it gets a sign-in page. + {/if} + </p> + <p class="subtle" style="max-width:38rem;font-size:13px"> + This applies to <strong>new drafts only</strong>. Your existing + {total === 1 ? "draft" : "drafts"} are not affected. + </p> + <form method="POST" action="?/setdefault"> + <input type="hidden" name="public" value={defaultPublic ? "false" : "true"} /> + <button class="btn" type="submit"> + {defaultPublic ? "Make new drafts private" : "Make new drafts public"} + </button> + </form> + {#if form?.defaultChanged} + <p class="subtle" style="margin-bottom:0;font-size:13px"> + Saved. New drafts will be {form.nowPublic ? "public" : "private"}. + </p> + {/if} + </div> +</div> + +<!-- EXISTING DRAFTS --> +<div class="card"> + <h3>Existing drafts</h3> + <p class="subtle" style="max-width:38rem;font-size:13px"> + You have <strong>{data.publicCount}</strong> public and + <strong>{data.privateCount}</strong> private + {total === 1 ? "draft" : "drafts"}. + </p> + <p class="subtle" style="max-width:38rem;font-size:13px"> + Changing these affects links you may already have shared. Making everything private + means anyone holding an old link sees a sign-in page instead of the document. + </p> + + {#if form?.bulkChanged !== undefined} + <p class="subtle" style="font-size:13px"> + {#if form.bulkChanged === 0} + No changes — every draft was already {form.nowPublic ? "public" : "private"}. + {:else} + {form.bulkChanged} + {form.bulkChanged === 1 ? "draft is" : "drafts are"} now + {form.nowPublic ? "public" : "private"}. + {/if} + </p> + {/if} + + <div class="row" style="gap:.75rem;flex-wrap:wrap"> + <form + method="POST" + action="?/bulk" + onsubmit={(e) => { + // Confirm with the REAL number. A generic "are you sure?" is easy to click past; + // naming the count is what makes the consequence land. + if ( + data.publicCount > 0 && + !confirm( + `Make ${data.publicCount} public ${data.publicCount === 1 ? "draft" : "drafts"} private?\n\n` + + `Anyone holding one of those links will get a sign-in page instead of the document.`, + ) + ) + e.preventDefault(); + }} + > + <input type="hidden" name="public" value="false" /> + <button class="btn btn--ghost" type="submit" disabled={data.publicCount === 0}> + Make all {data.publicCount > 0 ? data.publicCount : ""} private + </button> + </form> + + <form + method="POST" + action="?/bulk" + onsubmit={(e) => { + if ( + data.privateCount > 0 && + !confirm( + `Make ${data.privateCount} private ${data.privateCount === 1 ? "draft" : "drafts"} public?\n\n` + + `Anyone with the link will be able to read ${data.privateCount === 1 ? "it" : "them"} without signing in.`, + ) + ) + e.preventDefault(); + }} + > + <input type="hidden" name="public" value="true" /> + <button class="btn btn--ghost" type="submit" disabled={data.privateCount === 0}> + Make all {data.privateCount > 0 ? data.privateCount : ""} public + </button> + </form> + </div> +</div> + +<div class="card"> + <h3>From the CLI</h3> + <pre class="mono" style="font-size:13px;overflow-x:auto"><code>agentdraft upload plan.md --private # publish this one private +agentdraft visibility private plan.md # flip an existing draft +agentdraft visibility private --default # default for new drafts +agentdraft visibility public --all # every existing draft +agentdraft list # shows a VISIBILITY column</code></pre> +</div> From edda47b32e7c4835c9a6b9347b2d2d0639de79fe Mon Sep 17 00:00:00 2001 From: Hitesh Sisara <hitesh.dev@sisara.in> Date: Sun, 9 Aug 2026 07:42:29 +0000 Subject: [PATCH 9/9] docs(privacy): document private drafts across README, SECURITY, API and CLI --- README.md | 20 ++++++++++++++++++++ SECURITY.md | 40 ++++++++++++++++++++++++++++++++++++++++ docs/api.md | 35 ++++++++++++++++++++++++++++++++++- docs/cli.md | 31 +++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 369cc73..89a7966 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,26 @@ The CLI records which draft a local file maps to, so re-running `upload` on the updates the same URL instead of littering new ones. Git branch, commit SHA and dirty state are captured automatically when you publish from inside a repository. +### Public or private links + +Drafts are public by default — anyone with the URL reads them, no sign-in. Flip one to +private and the URL is unchanged, but only you can open it: + +```bash +agentdraft upload plan.md --private # publish it owner-only +agentdraft visibility private plan.md # flip an existing draft +agentdraft visibility private --default # default for NEW drafts +agentdraft visibility public --all # every existing draft +``` + +A private link still works for you: it routes through the dashboard, which verifies you own +the draft. Anyone else lands on sign-in and is returned to that exact document afterwards. +The account default and a per-draft toggle are also in the dashboard under **Privacy**. + +Changing the default affects **new drafts only** — links you already shared with reviewers +are never silently privatised. `--all` is the explicit opt-in and reports how many drafts +actually changed. + ### Security model for untrusted HTML Uploaded documents are treated as hostile, because they are: diff --git a/SECURITY.md b/SECURITY.md index 7ebe0ea..b5c6c48 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -142,6 +142,46 @@ KV-backed fixed-window counters, keyed on the API key (or IP for unauthenticated uploads 100/hour, reads 1000/hour. Fixed windows are coarse but adequate for abuse prevention on the free tier without Durable Objects. +## Draft visibility (private drafts) + +A draft is `public` (anyone with the URL) or `private` (owner only). Public is the default. + +**Entropy is not the control.** Draft ids are 22 chars of base36 (~113 bits; legacy ids are +12 chars / ~62 bits and keep working). Even 62 bits was not enumerable in practice — +roughly 7.5 years to a first hit at 100k req/s against 100k drafts, before rate limiting. +The real exposure was never guessing: it was that URLs **leak** (chat, PR comments, CI +logs, `Referer` headers) and, once leaked, granted permanent unrevocable read. Access +control fixes that; entropy does not. Treat the id length as defence in depth only. + +**How a private draft is served.** The content origin is cookie-free by design, because it +serves attacker-controlled HTML — a session readable there would be reachable by a CSP +bypass in a published document. It therefore cannot authorize a viewer, and does not try: +it `302`s to the dashboard, which holds the session, verifies ownership, and streams the +bytes on its own origin under the same CSP (including a `sandbox` directive in the header, +so a direct navigation is sandboxed even with no embedding iframe) plus +`Cache-Control: private, no-store`. + +That means a private draft renders on the **dashboard** origin, which is the property the +two-origin split otherwise avoids. It is accepted deliberately and mitigated by the +header-level `sandbox` + `script-src 'none'`; public drafts keep the stricter isolated path +unchanged. + +**Existence is not disclosed.** A non-owner receives `404` on every visibility and content +endpoint, never `403`. The content origin's `302` does reveal that *some* draft exists at a +guessed id — an existence oracle that is not exploitable at 113 bits, and collapsing it +into `404` would make an owner's typo indistinguishable from someone else's private draft. + +**Changing the account default is not retroactive.** It applies to new drafts only. +Silently privatising links already handed to reviewers is the one behaviour a preference +must not have, so existing drafts change only through an explicit bulk action that reports +how many rows it touched. + +**Return-to after sign-in is allow-listed.** A signed-out viewer of a private draft is sent +to `/login?next=…` and returned to that document. `next` is validated against a path +allow-list (`safeNext`), not merely checked for a leading `/`: scheme-relative `//evil`, +backslash, percent-encoded and control-character variants are all rejected, or the sign-in +page becomes an open redirect wearing our domain. + ## What v1 does NOT include - **No anonymous uploads.** Publishing requires an owner-tied, revocable key. Public diff --git a/docs/api.md b/docs/api.md index f51d531..1820c8e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -111,6 +111,26 @@ GET /api/drafts/:id Metadata + version list DELETE /api/drafts/:id Soft-delete ``` +### Visibility *(scope: upload)* +``` +PATCH /api/drafts/:id/visibility { "public": bool } one draft +POST /api/drafts/visibility/bulk { "public": bool } EVERY draft in the account +PATCH /api/me/settings { "default_draft_public": bool } +GET /api/drafts/:id/content owner-only bytes (?v=<n>, ?raw=1) +``` +A draft is public by default. `PATCH /api/me/settings` changes the default for **new +drafts only** — existing drafts are never touched by it, because silently flipping links +already shared with reviewers is the one behaviour a preference must not have. The bulk +endpoint is the explicit opt-in and returns `changed`, the number of rows it actually +modified. + +A non-owner gets `404`, not `403`, on every one of these: whether a draft exists is not +disclosed to someone who cannot read it. + +`GET /api/drafts/:id/content` exists because the content origin is cookie-free and cannot +authorize anyone (see below). It streams the document with the same hardening headers plus +`Cache-Control: private, no-store`. + ### Serving *(no auth, content origin)* ``` GET /d/:id Latest version @@ -120,7 +140,20 @@ GET /d/:id/v/:n/raw Alias ``` Response headers include a strict CSP, `X-Robots-Tag: noindex`, `Cross-Origin-Resource-Policy`, `X-AgentDraft-Draft-Id`, `X-AgentDraft-Version`, and an `ETag` -of the content hash. Disabled drafts return 451; private drafts 403; missing 404. +of the content hash. Disabled drafts return 451; missing 404. + +**Private drafts `302` to the dashboard**, they are never served here. This origin serves +attacker-controlled HTML, so a session must never be readable on it — which means it +cannot authorize a viewer. It redirects to `<dashboard>/private/d/:id…`, where the session +lives; the dashboard verifies ownership and streams the same bytes. The shared URL is +unchanged, so an owner just opens the link and sees the document, while anyone else lands +on sign-in and is returned to that exact draft afterwards. The redirect carries +`Cache-Control: private, no-store` and is a `302`, never a `301`, because visibility is +mutable and a permanent redirect would be cached past a later change. + +`Cross-Origin-Resource-Policy` is `same-site`, not `same-origin`: CORP is a second +embedding gate independent of `frame-ancestors`, and `same-origin` blocks the dashboard's +own preview iframe while producing an identical blank frame. **Conditional requests** are supported: send `If-None-Match: <etag>` to get a `304 Not Modified` with no body (handles `*`, `W/` weak tags, and comma-separated lists). This is diff --git a/docs/cli.md b/docs/cli.md index bfc0d0d..8278471 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -43,16 +43,47 @@ uploaded before. --description <text> Short label --title <text> Override the extracted <title> --idempotency-key <k> Dedupe retries (same key = same version) +--private Create it owner-only (overrides the account default) +--public Create it public (overrides the account default) ``` Output: public URL, draft id, version number, title. Warnings print to stderr. Blocked HTML fails with the server's validation error codes. +`--private` / `--public` apply only when the draft is CREATED. Re-uploading to an existing +draft never changes its visibility — silently flipping a link you already shared would be a +nasty surprise. Use `agentdraft visibility` for that. + +### `agentdraft visibility <public|private> [draft-id|url|file]` +Change who can read a draft. The URL never changes. +``` +--all Apply to EVERY existing draft in the account +--default Set the default for NEW drafts (existing drafts untouched) +``` +The target accepts a draft id, a full content URL, or a local file path you have uploaded +before (resolved through the same `drafts.json` mapping `upload` uses). + +```bash +agentdraft visibility private plan.md # the draft this file publishes to +agentdraft visibility public a1b2c3d4e5f6 # by id +agentdraft visibility private --default # only affects NEW drafts +agentdraft visibility public --all # every existing draft +``` + +A private draft's link still works **for you**: it redirects through the dashboard, which +verifies you own it. Anyone else gets a sign-in page. `--all` reports how many drafts +actually changed, so a no-op says so instead of claiming success. + ### `agentdraft fetch <url|draft-id>` Download served HTML. ``` -o, --output <file> Write to a file instead of stdout --version <n> Fetch a specific version ``` +Works on PRIVATE drafts too, provided you own them: the content origin answers with a +redirect, and the CLI retries through the authenticated API rather than following it. That +matters because following the redirect would write the dashboard's HTML **login page** to +your output file and exit 0 — a silent wrong-content success. With no stored credentials +the command exits 1 and writes nothing. ### `agentdraft list` List your drafts.