From d5b8bb4d02c626d7a951d62afc87e9978c6f0f72 Mon Sep 17 00:00:00 2001 From: mxrsv Date: Fri, 18 Sep 2026 01:01:18 +0700 Subject: [PATCH 01/20] fix(agents): launch Codex with its idle animations off Codex 0.154 repaints a startup spinner and a starfield behind its prompt about twelve times a second for as long as the pane sits idle. Deck's sustained-output heuristic reads that as a working agent, so every Codex row showed the busy bars before the first prompt and kept them after the last reply, and the tail store never re-asked because the pane's state never changed (DECK-121). `-c tui.animations=false` is Codex's own switch. Measured in the dev host on the same pane: plain `codex` idle emits 149 chunks in 12 s and the rail reads `working` from 2 s on, forever; with the flag it emits none, the startup paint ends at 2.6 s and the rail reads `idle`. The flag rides on the default command and on every resume form, and a `No idle animations` toggle exposes it in Settings > Agents so the shipped command parses without a remainder. The registry test now checks the bare form's binary rather than the whole string. Claude-Session: https://claude.ai/code/session_01VekypU5jZszKu4y1MLDDq8 --- src/lib/agent-resume.test.ts | 10 +++++-- src/lib/agents/agent-registry.test.ts | 4 ++- src/lib/agents/codex.ts | 30 ++++++++++++++++--- src/sessions/resume-session.test.ts | 4 ++- src/terminal/session-restore.test.ts | 4 ++- .../tab-manager.drop-agent-pane.test.ts | 2 +- .../settings/launch-profile-editor.test.tsx | 2 +- 7 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/lib/agent-resume.test.ts b/src/lib/agent-resume.test.ts index af7bff51..4450850b 100644 --- a/src/lib/agent-resume.test.ts +++ b/src/lib/agent-resume.test.ts @@ -20,15 +20,19 @@ describe("buildResumeCommand — claude", () => { describe("buildResumeCommand — codex", () => { it("id ref", () => { + // Every Codex command Deck types carries `-c tui.animations=false` + // (DECK-121): the idle starfield otherwise reads as a working agent. expect(buildResumeCommand("codex", { kind: "id", id: "abc123" }, NO_CUSTOM)).toBe( - "codex resume abc123", + "codex resume abc123 -c tui.animations=false", ); }); it("latest ref", () => { - expect(buildResumeCommand("codex", { kind: "latest" }, NO_CUSTOM)).toBe("codex resume --last"); + expect(buildResumeCommand("codex", { kind: "latest" }, NO_CUSTOM)).toBe( + "codex resume --last -c tui.animations=false", + ); }); it("null ref", () => { - expect(buildResumeCommand("codex", null, NO_CUSTOM)).toBe("codex"); + expect(buildResumeCommand("codex", null, NO_CUSTOM)).toBe("codex -c tui.animations=false"); }); }); diff --git a/src/lib/agents/agent-registry.test.ts b/src/lib/agents/agent-registry.test.ts index 53ef2d17..7d353cf6 100644 --- a/src/lib/agents/agent-registry.test.ts +++ b/src/lib/agents/agent-registry.test.ts @@ -15,7 +15,9 @@ describe("agent registry", () => { expect(new Set(KNOWN_IDS).size).toBe(KNOWN_IDS.length); for (const agent of AGENT_DEFINITIONS) { expect((agent.defaultCommand ?? agent.id).split(" ")[0]).toBe(agent.id); - expect(agent.resume.bare).toBe(agent.id); + // The bare form may carry flags Deck always types (codex's + // `-c tui.animations=false`, DECK-121); the binary is still the id. + expect(agent.resume.bare.split(" ")[0]).toBe(agent.id); } }); diff --git a/src/lib/agents/codex.ts b/src/lib/agents/codex.ts index 4583e5b0..0473b408 100644 --- a/src/lib/agents/codex.ts +++ b/src/lib/agents/codex.ts @@ -3,6 +3,21 @@ import { toggle, valued, type AgentDefinition } from "./agent-definition"; const SANDBOX = ["--sandbox", "-s"]; const APPROVAL = ["--ask-for-approval", "-a"]; +/** + * Codex 0.154's TUI animates while idle: a startup spinner and a starfield + * behind the composer, repainted ~12 times a second for as long as the pane + * sits at its prompt (measured 2026-09-18, DECK-121). Deck's sustained-output + * heuristic cannot tell that from a working agent, so every Codex row showed + * the busy bars before the first prompt and kept them after the last reply. + * `tui.animations=false` is Codex's own switch for it; with it the pane goes + * silent 2.6 s after launch and stays silent at the prompt. Carried on every + * command Deck types, resume included, so a restored pane behaves the same. + * A user-written launch command does not inherit it — the `quiet` control + * below and `docs/user/agents.md` say what to add. + */ +const NO_ANIMATIONS_FORM = ["-c", "tui.animations=false"] as const; +const NO_ANIMATIONS = NO_ANIMATIONS_FORM.join(" "); + /** * Codex. Launch flags read off `codex --help` 0.154.0 on 2026-09-11; model on * 2026-08-24 (`-m, --model `, no effort flag). @@ -10,13 +25,13 @@ const APPROVAL = ["--ask-for-approval", "-a"]; export const CODEX: AgentDefinition = { id: "codex", label: "Codex", - defaultCommand: "codex --dangerously-bypass-approvals-and-sandbox", + defaultCommand: `codex --dangerously-bypass-approvals-and-sandbox ${NO_ANIMATIONS}`, url: "https://developers.openai.com/codex/cli", dotColor: "var(--green)", resume: { - id: (id) => `codex resume ${id}`, - latest: "codex resume --last", - bare: "codex", + id: (id) => `codex resume ${id} ${NO_ANIMATIONS}`, + latest: `codex resume --last ${NO_ANIMATIONS}`, + bare: `codex ${NO_ANIMATIONS}`, }, runtime: { modelFlag: "--model", models: [], effortFlag: null, efforts: [] }, launchFlags: [ @@ -51,5 +66,12 @@ export const CODEX: AgentDefinition = { toggle("inline", "Inline mode", "Keep terminal scrollback instead of an alternate screen.", [ "--no-alt-screen", ]), + toggle( + "quiet", + "No idle animations", + "Turn off the startup spinner and the prompt background, which Deck would otherwise read as work in progress.", + NO_ANIMATIONS_FORM, + ["--config", "tui.animations=false"], + ), ], }; diff --git a/src/sessions/resume-session.test.ts b/src/sessions/resume-session.test.ts index 937b848a..e21b8ea1 100644 --- a/src/sessions/resume-session.test.ts +++ b/src/sessions/resume-session.test.ts @@ -38,7 +38,9 @@ describe("resumeSession", () => { it("uses codex's own resume form", async () => { const d = deps(); await resumeSession(entry({ agent: "codex", sessionId: "abc123" }), d); - expect(vi.mocked(d.materialize).mock.calls[0][0].paneCommands).toEqual(["codex resume abc123"]); + expect(vi.mocked(d.materialize).mock.calls[0][0].paneCommands).toEqual([ + "codex resume abc123 -c tui.animations=false", + ]); }); // A dead cwd landing in $HOME is worse than not resuming (spec §4). diff --git a/src/terminal/session-restore.test.ts b/src/terminal/session-restore.test.ts index cf3190c4..baa16281 100644 --- a/src/terminal/session-restore.test.ts +++ b/src/terminal/session-restore.test.ts @@ -396,7 +396,9 @@ describe("restoreSession", () => { const { deps, mocks } = createFakeDeps({ records, lookup }); await restoreSession(deps, "main"); const [intent] = mocks.materialize.mock.calls[0]; - expect(intent.paneCommands).toEqual(["codex resume abc123"]); + // Codex's own resume form, with the flag Deck always types (DECK-121) and + // nothing folded in from the recorded launch command. + expect(intent.paneCommands).toEqual(["codex resume abc123 -c tui.animations=false"]); }); it("restores a pane with no recorded options exactly as before", async () => { diff --git a/src/terminal/tab-manager.drop-agent-pane.test.ts b/src/terminal/tab-manager.drop-agent-pane.test.ts index 2a76f4a1..0416f3a0 100644 --- a/src/terminal/tab-manager.drop-agent-pane.test.ts +++ b/src/terminal/tab-manager.drop-agent-pane.test.ts @@ -78,7 +78,7 @@ describe("createTabManager dropAgentPane", () => { expect(tabViews.value.length).toBe(tabCount); expect(tm.allPaneIds()).toEqual([1, 2]); expect(pty.writes).toEqual([ - { id: 2, data: "codex --dangerously-bypass-approvals-and-sandbox\r" }, + { id: 2, data: "codex --dangerously-bypass-approvals-and-sandbox -c tui.animations=false\r" }, ]); tm.dispose(); }); diff --git a/src/ui/settings/launch-profile-editor.test.tsx b/src/ui/settings/launch-profile-editor.test.tsx index f8451647..3b5a26c2 100644 --- a/src/ui/settings/launch-profile-editor.test.tsx +++ b/src/ui/settings/launch-profile-editor.test.tsx @@ -327,7 +327,7 @@ describe("LaunchProfileEditor", () => { expect(inPanel("claude", "No approvals or sandbox")).toBeNull(); // The field holds the whole command, so focusing it shows what launches. expect((byLabel("Command for Codex") as HTMLInputElement).value).toBe( - "codex --dangerously-bypass-approvals-and-sandbox", + "codex --dangerously-bypass-approvals-and-sandbox -c tui.animations=false", ); }); From 6216b5d7d30327d2bc6a87ab0b1330e7439886c6 Mon Sep 17 00:00:00 2001 From: mxrsv Date: Fri, 18 Sep 2026 01:27:35 +0700 Subject: [PATCH 02/20] feat(feedback): persist moderated Google feedback and email updates --- CHANGELOG.md | 7 + backend/README.md | 107 ++- backend/migrations/0003-feedback.sql | 43 + backend/src/contract.test.mjs | 12 +- backend/src/feedback-auth.mjs | 96 +++ backend/src/feedback-mail.mjs | 54 ++ backend/src/feedback-payload.mjs | 4 +- backend/src/feedback-repository.mjs | 214 +++++ backend/src/feedback-routes.mjs | 146 ++-- backend/src/feedback-sync.mjs | 34 + backend/src/feedback-webhook.mjs | 86 ++ backend/src/feedback.test.mjs | 760 ++++++++++++------ backend/src/linear-feedback.mjs | 114 +-- backend/src/runtime.test.mjs | 34 + backend/src/worker.mjs | 12 +- backend/wrangler.jsonc | 35 +- marketing/landing-prototype/src/copy.js | 30 +- .../landing-prototype/src/feedback-api.js | 49 +- .../src/feedback-api.test.js | 129 ++- .../landing-prototype/src/feedback-auth.js | 84 ++ .../src/feedback-board-view.js | 9 +- .../src/feedback-controller.test.js | 56 ++ .../landing-prototype/src/feedback-demo.js | 69 +- .../landing-prototype/src/feedback-draft.js | 2 +- .../src/feedback-form-view.js | 21 +- .../src/feedback-view.test.js | 74 ++ marketing/landing-prototype/src/feedback.js | 87 +- .../styles/feedback-board.css | 24 + .../public/privacy/2026-09-18/index.html | 430 ++++++++++ vercel.json | 6 +- 30 files changed, 2276 insertions(+), 552 deletions(-) create mode 100644 backend/migrations/0003-feedback.sql create mode 100644 backend/src/feedback-auth.mjs create mode 100644 backend/src/feedback-mail.mjs create mode 100644 backend/src/feedback-repository.mjs create mode 100644 backend/src/feedback-sync.mjs create mode 100644 backend/src/feedback-webhook.mjs create mode 100644 marketing/landing-prototype/src/feedback-auth.js create mode 100644 marketing/landing-prototype/src/feedback-controller.test.js create mode 100644 marketing/landing-prototype/src/feedback-view.test.js create mode 100644 marketing/public/privacy/2026-09-18/index.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 214ef2ce..0679bce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ the release PR, and frozen at the tag — never an auto-generated commit list. ## Unreleased +### Feedback + +- The [feedback page](marketing/landing-prototype/src/feedback.js) supports Google sign-in, + durable private submissions, owner-approved public descriptions, older feedback pages, + and approval/progress email updates. Sending remains closed until service configuration + and rollout verification are complete. + ### Usage - **See remaining allowance before historical cost.** The redesigned diff --git a/backend/README.md b/backend/README.md index b70705aa..551d138a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -3,7 +3,7 @@ Specification and decisions: [DECK-1](https://linear.app/mxrsv/issue/DECK-1) `decided`. This directory is deployed independently from the desktop and landing. It shares no database with sibling SpaceVibe products. Entry point: [Worker](src/worker.mjs) `current`; -[deployment configuration](wrangler.jsonc) `current`; [privacy notice](../marketing/public/privacy/2026-09-12/index.html) `current`. +[deployment configuration](wrangler.jsonc) `current`; [privacy notice](../marketing/public/privacy/2026-09-18/index.html) `current`. ## Run and deploy @@ -37,7 +37,8 @@ prevents stale retries from reinserting identifiers already removed by retention 204 means the D1 upsert completed. 400/413 are terminal, 429/503 retryable. Other routes return 404 and non-POST ingest returns 405. No route reads or exports usage data; the one -read route, the feedback board below, never touches D1. A shared +read route, the feedback board below, reads only feedback tables through the +[feedback repository](src/feedback-repository.mjs). A shared 1000-request/minute, per-Cloudflare-location limiter bounds writes without reading IPs; it is best effort, not a strict global spending cap. Its namespace is service-specific. @@ -55,35 +56,89 @@ and [the update-counter column](migrations/0002-update-counters.sql). ## Feedback -The landing's `/feedback` page ([DECK-101](https://linear.app/mxrsv/issue/DECK-101)) talks to -[`/v1/feedback`](src/feedback-routes.mjs), which keeps nothing in D1: Linear is the only store. -`POST` [validates](src/feedback-payload.mjs) a title, body, category and optional draft UUID (up -to 16 KB, so 2,000 characters fit in any script), drops a filled honeypot with a silent 204, -applies its own `FEEDBACK_LIMITER`, then creates an issue in SpaceVibe-Deck with the `Feedback` -label in **Backlog**. The visitor's text sits in a fenced block under `## User report`, with -linear.app links defused so a paste cannot mention anyone; `Other` gets `Needs decision` instead -of a Type label. The draft UUID becomes the issue id, so a resend after a lost answer finds the -issue that already landed instead of creating a second one. `GET` returns the board — identifier, -title, category, column and update time, never the description — edge-cached for 60 seconds per -Cloudflare location. CORS allows only `deck.spacevibe.dev` and the local landing preview. - -Backlog is the moderation gate: nothing there is public. Moving an issue to Todo publishes its -title, so rewrite a title in Linear before publishing it. The [board query](src/linear-feedback.mjs) -asks Linear only for published state types and maps by type: unstarted → pending, started → -review, completed → done (30 most recent). Every connection names its own `first`: Linear's -complexity budget is per user and shared with every other tool using the key owner's account. - -With logging off, the `17 * * * *` [probe](src/linear-feedback.mjs) is the only signal that -submissions fail: it rejects when the key, team, labels or Backlog state are missing or archived, -so check that cron's invocations after any key or workflow change. The landing keeps sending -closed (`SUBMISSIONS_OPEN` in [feedback-api.js](../marketing/landing-prototype/src/feedback-api.js)) -until the Worker ships; flip it only after a probe passes. Team, label and Backlog IDs are `vars` -in [wrangler.jsonc](wrangler.jsonc); the key is a secret, and without it both routes answer 503: +[DECK-101](https://linear.app/mxrsv/issue/DECK-101) uses [D1 storage](src/feedback-repository.mjs) +as its source of truth. [POST /v1/feedback](src/feedback-routes.mjs) requires a +[verified Google ID token](src/feedback-auth.mjs), validates the report and required draft UUID, +and returns 201 only after saving a private pending record. A repeat of the same payload and +Google subject/draft ID returns the existing receipt; changed content returns 409. Public and +Linear issue IDs are generated by the server. A filled honeypot is rejected, never acknowledged +as saved. Neither a Linear outage nor an email failure can discard accepted feedback. + +The [minute job](src/feedback-sync.mjs) creates a Backlog issue and retries using its stable +server-generated UUID. The owner approves in Linear: Todo publishes Pending; In Progress, +Blocked and Ready for Review map to the middle column; Done remains public. Backlog, cancellation, +duplicate, manual archive or removal of the Feedback label hide the report. A signed +[Issue webhook](src/feedback-webhook.mjs) persists refresh requests and hides deleted issues. +Only D1-owned issue IDs are processed. A missing/inaccessible API response is not treated as +proof of deletion. Webhook delivery and polling are complementary: five records rotate per minute, +so synchronization can take longer than a minute as the backlog grows. + +[GET /v1/feedback](src/feedback-routes.mjs) needs no login. It returns original submitted title, +description, category, status, dates and identifiers; never email, Google subject or internal +Linear description. Editing Linear text does not rewrite the original public report: hide an +inappropriate report rather than assuming a Linear edit redacts it. Reads use no-store and an +opaque pagination cursor; there is no permanent cap on old Done items. Feedback has no automatic +expiry (including automatically archived Linear Done issues) and is excluded from [analytics retention](src/usage-repository.mjs). Hiding or deleting +an issue hides the public card but retains the D1 record; it is not a personal-data erasure. + +Publication and notification outbox entries commit in one D1 transaction. The first publication +and first In Progress milestone each receive one [Resend notification](src/feedback-mail.mjs). +Signed intermediate In Progress events are retained even when the next poll already sees Done. +Email payloads and idempotency keys remain stable on retries. Ambiguous deliveries stop retrying +after 23 hours (before Resend's 24-hour key expiry) and set `needs_review = 1`; check provider +receipts before manually reconciling them. This avoids claiming exactly-once email delivery. +Hidden reports do not start pending email deliveries; a provider request already in flight cannot +be recalled. Email recipient and title are stored privately in the outbox. + +### Configuration and opening intake + +The [deployment defaults](wrangler.jsonc) keep both `FEEDBACK_SYNC_ENABLED` and +`FEEDBACK_SUBMISSIONS_OPEN` false. After approval, apply [0003-feedback.sql](migrations/0003-feedback.sql) +with the existing migration command. The migration adds tables without rewriting usage data. +Configure a Google web client with `https://deck.spacevibe.dev` as an authorized JavaScript origin +(and the documented localhost preview origins if needed), then set `GOOGLE_CLIENT_ID` as a Worker +variable. Sign-in accepts verified Gmail and Google Workspace email; no Gmail inbox permission, +Google client secret, refresh token or stored browser credential is needed. + +Configure these Worker secrets using Wrangler's interactive prompts; never paste values into +an issue, source file or command argument: ```sh npx wrangler secret put LINEAR_API_KEY +npx wrangler secret put LINEAR_WEBHOOK_SECRET +npx wrangler secret put RESEND_API_KEY ``` +Set `FEEDBACK_EMAIL_FROM` to a verified Resend sender. Create a team-scoped Linear Issue webhook +at `https://api.deck.spacevibe.dev/v1/feedback/linear-webhook`; its signing secret must match. +Check `FEEDBACK_TEAM_ID`, `FEEDBACK_LABEL_ID`, `FEEDBACK_BACKLOG_STATE_ID` and +`FEEDBACK_PROGRESS_STATE_ID` against the workflow. Google client ID and sender are configuration, +not fabricated defaults. The [configuration endpoint](src/feedback-routes.mjs) refuses to open +intake when a required value or synchronization is absent. + +After deploying the Worker, enable synchronization and verify the hourly +[configuration probe](src/linear-feedback.mjs). Review the dated privacy notice before publishing +it. Enable `FEEDBACK_BOARD_OPEN` in the [landing API module](../marketing/landing-prototype/src/feedback-api.js) +for public reads. Open the Worker submission switch and landing `SUBMISSIONS_OPEN` only as part of +an authorized acceptance run: sign in, submit, approve, move to In Progress, confirm both emails, +then hide the report. A local build or mocked provider test does not establish these results. +The [dev-only demo](../marketing/landing-prototype/src/feedback-demo.js) remains available for +visual review without production credentials. + +For recovery, close intake while keeping synchronization and public reads enabled. Preserve D1 +records and pending jobs; do not revert to the old direct-to-Linear writer or drop tables. +Check scheduled invocation outcomes and count-only outbox queries (no email/body exports): + +```sql +SELECT count(*) AS unsynced FROM feedback WHERE linear_synced = 0 AND deleted_at IS NULL; +SELECT count(*) AS pending_mail FROM feedback_mail WHERE sent_at IS NULL AND needs_review = 0; +SELECT count(*) AS mail_needing_review FROM feedback_mail WHERE needs_review = 1; +``` + +There is no external failure alert configured. A failed minute invocation needs investigation; +a skipped lease means another invocation owns the bounded batch. Provider acceptance is not proof +of inbox delivery. Pending runtime and owner acceptance are tracked on the issue. + ## Operations and privacy Worker logging, invocation logging, tracing and Logpush are disabled. Do not use `wrangler diff --git a/backend/migrations/0003-feedback.sql b/backend/migrations/0003-feedback.sql new file mode 100644 index 00000000..9f7d8a0f --- /dev/null +++ b/backend/migrations/0003-feedback.sql @@ -0,0 +1,43 @@ +-- Feedback has owner-controlled retention, independent of usage_days cleanup. +CREATE TABLE feedback ( + id TEXT PRIMARY KEY, + draft_id TEXT NOT NULL, + google_sub TEXT NOT NULL, + email TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + category TEXT NOT NULL CHECK (category IN ('bug', 'idea', 'other')), + status TEXT NOT NULL DEFAULT 'private' CHECK (status IN ('private', 'pending', 'review', 'done', 'hidden')), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + linear_identifier TEXT, + linear_synced INTEGER NOT NULL DEFAULT 0, + source_updated_at INTEGER NOT NULL DEFAULT 0, + checked_at INTEGER NOT NULL DEFAULT 0, + deleted_at INTEGER, + UNIQUE (google_sub, draft_id) +); +CREATE INDEX feedback_public ON feedback(status, created_at DESC, id DESC); +CREATE INDEX feedback_sync ON feedback(linear_synced, checked_at); + +CREATE TABLE feedback_mail ( + id TEXT PRIMARY KEY, + feedback_id TEXT NOT NULL REFERENCES feedback(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('approved', 'progress')), + recipient TEXT NOT NULL, + title TEXT NOT NULL, + created_at INTEGER NOT NULL, + first_attempt_at INTEGER, + next_attempt_at INTEGER NOT NULL DEFAULT 0, + sent_at INTEGER, + needs_review INTEGER NOT NULL DEFAULT 0, + UNIQUE (feedback_id, kind) +); +CREATE INDEX feedback_mail_pending ON feedback_mail(sent_at, needs_review, next_attempt_at); + +CREATE TABLE feedback_jobs ( + name TEXT PRIMARY KEY, + lease_token TEXT, + lease_until INTEGER NOT NULL DEFAULT 0 +); +INSERT INTO feedback_jobs(name) VALUES ('sync'); diff --git a/backend/src/contract.test.mjs b/backend/src/contract.test.mjs index 561749bc..4d8d6104 100644 --- a/backend/src/contract.test.mjs +++ b/backend/src/contract.test.mjs @@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; import { build } from "esbuild"; +import { FEEDBACK_SYNC_CRON } from "./feedback-sync.mjs"; import { FEEDBACK_PROBE_CRON } from "./linear-feedback.mjs"; import { AGENT_KEYS, SURFACE_KEYS, UPDATE_KEYS, COUNTER_CAP, validPayload } from "./payload.mjs"; @@ -80,14 +81,15 @@ test("deployment disables logs, traces, public preview URLs and exposes only the // drifted schedule would keep raw rows past it and nothing else would notice, // because logs and traces are off by design. // The second cron is the hourly feedback probe; the Worker dispatches on it. - assert.deepEqual(config.triggers.crons, ["0 3 * * *", FEEDBACK_PROBE_CRON]); + assert.deepEqual(config.triggers.crons, ["0 3 * * *", FEEDBACK_PROBE_CRON, FEEDBACK_SYNC_CRON]); }); test("privacy routes publish the dated notice and include its source in the deployment", async () => { const config = JSON.parse(await readFile(new URL("../../vercel.json", import.meta.url), "utf8")); // `/privacy` serves the newest notice; every earlier dated copy stays reachable. for (const [source, notice] of [ - ["/privacy", "2026-09-12"], + ["/privacy", "2026-09-18"], + ["/privacy/2026-09-18", "2026-09-18"], ["/privacy/2026-09-12", "2026-09-12"], ["/privacy/2026-09-07", "2026-09-07"], ]) { @@ -101,7 +103,7 @@ test("privacy routes publish the dated notice and include its source in the depl const ignore = await readFile(new URL("../../.vercelignore", import.meta.url), "utf8"); assert.ok(ignore.includes("!/marketing/public")); const html = await readFile( - new URL("../../marketing/public/privacy/2026-09-12/index.html", import.meta.url), + new URL("../../marketing/public/privacy/2026-09-18/index.html", import.meta.url), "utf8", ); assert.doesNotMatch(html, /anonymous/i); @@ -116,6 +118,10 @@ test("privacy routes publish the dated notice and include its source in the depl "no in-app opt-out", "1.0.0", "Share usage stats", + "Google", + "Resend", + "private until", + "do not automatically expire", ]) { assert.ok(html.replace(/\s+/g, " ").includes(term), term); } diff --git a/backend/src/feedback-auth.mjs b/backend/src/feedback-auth.mjs new file mode 100644 index 00000000..3bde4396 --- /dev/null +++ b/backend/src/feedback-auth.mjs @@ -0,0 +1,96 @@ +import { PayloadError } from "./payload.mjs"; + +const GOOGLE_KEYS = "https://www.googleapis.com/oauth2/v3/certs"; +const ISSUERS = ["https://accounts.google.com", "accounts.google.com"]; +const TOKEN_MAX_BYTES = 8192; +const FETCH_TIMEOUT_MS = 5000; +const MAX_KEY_CACHE_MS = 60 * 60_000; +let cachedKeys = null; + +function decode(part) { + if (!/^[A-Za-z0-9_-]+$/.test(part)) throw new PayloadError(401); + try { + return Uint8Array.from(atob(part.replace(/-/g, "+").replace(/_/g, "/")), (c) => + c.charCodeAt(0), + ); + } catch { + throw new PayloadError(401); + } +} + +function decodeJson(part) { + try { + return JSON.parse(new TextDecoder().decode(decode(part))); + } catch { + throw new PayloadError(401); + } +} + +async function signingKey(kid, now) { + // A fixed trusted endpoint: never follow token-supplied jku/x5u URLs. + if (!cachedKeys || cachedKeys.expires <= now) { + const response = await fetch(GOOGLE_KEYS, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!response.ok) throw new Error("Google keys unavailable"); + const body = await response.json(); + if (!Array.isArray(body.keys)) throw new Error("Invalid Google key response"); + const maxAge = Number(response.headers.get("cache-control")?.match(/max-age=(\d+)/)?.[1] ?? 0); + cachedKeys = { keys: body.keys, expires: now + Math.min(maxAge * 1000, MAX_KEY_CACHE_MS) }; + } + const key = cachedKeys.keys.find( + (item) => item.kid === kid && item.kty === "RSA" && item.alg === "RS256" && item.use === "sig", + ); + if (!key) throw new PayloadError(401); + return crypto.subtle.importKey( + "jwk", + key, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ); +} + +/** GIS callback credentials are bearer tokens kept only in the page's memory. */ +export async function authenticateFeedback(request, env, now = Date.now()) { + if (!env.GOOGLE_CLIENT_ID) throw new Error("Google sign-in is not configured"); + const authorization = request.headers.get("authorization") ?? ""; + if (!authorization.startsWith("Bearer ") || authorization.length > TOKEN_MAX_BYTES) + throw new PayloadError(401); + const parts = authorization.slice(7).split("."); + if (parts.length !== 3) throw new PayloadError(401); + const header = decodeJson(parts[0]); + const claims = decodeJson(parts[1]); + if (header?.alg !== "RS256" || typeof header.kid !== "string" || header.crit) + throw new PayloadError(401); + const key = await signingKey(header.kid, now); + const valid = await crypto.subtle.verify( + "RSASSA-PKCS1-v1_5", + key, + decode(parts[2]), + new TextEncoder().encode(`${parts[0]}.${parts[1]}`), + ); + if (!valid || !validClaims(claims, env.GOOGLE_CLIENT_ID, now)) throw new PayloadError(401); + return { sub: claims.sub, email: claims.email }; +} + +function validClaims(c, audience, now) { + return ( + c && + ISSUERS.includes(c.iss) && + c.aud === audience && + (!c.azp || c.azp === audience) && + Number.isFinite(c.exp) && + c.exp * 1000 > now && + Number.isFinite(c.iat) && + c.iat * 1000 <= now + 60_000 && + (c.nbf === undefined || (Number.isFinite(c.nbf) && c.nbf * 1000 <= now)) && + typeof c.sub === "string" && + c.sub.length > 0 && + c.sub.length <= 255 && + c.email_verified === true && + typeof c.email === "string" && + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email) && + c.email.length <= 254 && + // Google is authoritative for Gmail and verified Workspace email only. + (c.email.toLowerCase().endsWith("@gmail.com") || (typeof c.hd === "string" && c.hd.length > 0)) + ); +} diff --git a/backend/src/feedback-mail.mjs b/backend/src/feedback-mail.mjs new file mode 100644 index 00000000..d7756817 --- /dev/null +++ b/backend/src/feedback-mail.mjs @@ -0,0 +1,54 @@ +const RESEND_ENDPOINT = "https://api.resend.com/emails"; +const TIMEOUT_MS = 10_000; +// Resend only remembers idempotency keys for 24h. Stop ambiguous retries +// before that expires; a maintainer reconciles delivery before retrying. +const SAFE_RETRY_WINDOW_MS = 23 * 60 * 60_000; +const BOARD_URL = "https://deck.spacevibe.dev/feedback"; + +export async function deliverFeedbackMail(env, repository, now = Date.now()) { + const pending = await repository.pendingMail(now); + let failed = 0; + for (const event of pending) { + try { + if (!env.RESEND_API_KEY || !env.FEEDBACK_EMAIL_FROM) throw new Error("Email not configured"); + if (event.first_attempt_at !== null && now - event.first_attempt_at >= SAFE_RETRY_WINDOW_MS) { + await repository.mailNeedsReview(event.id); + failed += 1; + continue; + } + const attempt = await repository.startMail(event.id, now); + if (!attempt) continue; + await sendMail(env, attempt); + await repository.mailSent(event.id, now); + } catch { + // The durable row remains pending; report only a count, never PII. + failed += 1; + } + } + return failed; +} + +async function sendMail(env, event) { + const progress = event.kind === "progress"; + const message = progress + ? "Work has started on your feedback." + : "Your feedback has been approved and is now public."; + const response = await fetch(RESEND_ENDPOINT, { + method: "POST", + headers: { + authorization: `Bearer ${env.RESEND_API_KEY}`, + "content-type": "application/json", + "idempotency-key": event.id, + }, + body: JSON.stringify({ + from: env.FEEDBACK_EMAIL_FROM, + to: [event.recipient], + subject: progress ? "Your Deck feedback is in progress" : "Your Deck feedback is approved", + text: `${message}\n\n${event.title}\n\nView the feedback board: ${BOARD_URL}\n\nYou received this update because you submitted feedback to SpaceVibe Deck.`, + }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (!response.ok) throw new Error("Email provider unavailable"); + const body = await response.json(); + if (typeof body.id !== "string" || !body.id) throw new Error("Email receipt missing"); +} diff --git a/backend/src/feedback-payload.mjs b/backend/src/feedback-payload.mjs index f9523dbe..5d1ba42c 100644 --- a/backend/src/feedback-payload.mjs +++ b/backend/src/feedback-payload.mjs @@ -9,7 +9,7 @@ export const FEEDBACK_CATEGORIES = ["bug", "idea", "other"]; export const TITLE_MIN = 3; export const TITLE_MAX = 120; export const BODY_MAX = 2000; -const REQUIRED_FIELDS = ["title", "category"]; +const REQUIRED_FIELDS = ["title", "category", "id"]; // `website` is a honeypot the landing keeps out of layout, where neither a // person nor browser autofill can reach it; `id` is the draft's UUID v4. const OPTIONAL_FIELDS = ["body", "website", "id"]; @@ -41,7 +41,7 @@ export function parseFeedback(value) { !REQUIRED_FIELDS.every((key) => Object.hasOwn(value, key)) || !Object.values(value).every((field) => typeof field === "string") || !FEEDBACK_CATEGORIES.includes(value.category) || - (value.id !== undefined && !UUID_V4.test(value.id)) + !UUID_V4.test(value.id) ) { return undefined; } diff --git a/backend/src/feedback-repository.mjs b/backend/src/feedback-repository.mjs new file mode 100644 index 00000000..8c63d30d --- /dev/null +++ b/backend/src/feedback-repository.mjs @@ -0,0 +1,214 @@ +import { PayloadError, UUID_V4 } from "./payload.mjs"; + +export const PAGE_SIZE = 30; +export const SYNC_BATCH_SIZE = 5; +export const PUBLIC_STATUSES = ["pending", "review", "done"]; +const LEASE_MS = 5 * 60_000; + +export function parseBoardCursor(value) { + if (!value) return null; + const [time, id, extra] = value.split(":"); + if (extra !== undefined || !/^\d{1,16}$/.test(time) || !UUID_V4.test(id)) { + throw new PayloadError(400); + } + const createdAt = Number(time); + if (!Number.isSafeInteger(createdAt)) throw new PayloadError(400); + return { createdAt, id }; +} + +function publicItem(row) { + return { + id: row.id, + identifier: row.linear_identifier, + title: row.title, + description: row.body, + category: row.category, + status: row.status, + updatedAt: new Date(row.updated_at).toISOString(), + }; +} + +/** SQL and identity never escape this boundary through a public projection. */ +export function createFeedbackRepository(db) { + const statement = (sql, ...args) => db.prepare(sql).bind(...args); + const findById = (id) => statement("SELECT * FROM feedback WHERE id = ?", id).first(); + + async function create(input, user, now = Date.now()) { + // The public/Linear id is server-generated; a visitor cannot collide with + // another user's id or an existing unrelated issue in the owner's workspace. + await statement( + `INSERT INTO feedback (id, draft_id, google_sub, email, title, body, category, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(google_sub, draft_id) DO NOTHING`, + crypto.randomUUID(), + input.id, + user.sub, + user.email, + input.title, + input.body, + input.category, + now, + now, + ).run(); + const row = await statement( + "SELECT * FROM feedback WHERE google_sub = ? AND draft_id = ?", + user.sub, + input.id, + ).first(); + if (!row) throw new Error("Feedback persistence failed"); + if (row.title !== input.title || row.body !== input.body || row.category !== input.category) { + throw new PayloadError(409); + } + return row.id; + } + + async function listPublic(cursor) { + const rows = await statement( + `SELECT id, linear_identifier, title, body, category, status, updated_at, created_at + FROM feedback WHERE status IN ('pending', 'review', 'done') AND deleted_at IS NULL + AND (? IS NULL OR created_at < ? OR (created_at = ? AND id < ?)) + ORDER BY created_at DESC, id DESC LIMIT ?`, + cursor?.createdAt ?? null, + cursor?.createdAt ?? null, + cursor?.createdAt ?? null, + cursor?.id ?? null, + PAGE_SIZE + 1, + ).all(); + const page = rows.results.slice(0, PAGE_SIZE); + const last = page.at(-1); + return { + items: page.map(publicItem), + nextCursor: rows.results.length > PAGE_SIZE ? `${last.created_at}:${last.id}` : null, + }; + } + + async function applyModeration(id, state, now = Date.now()) { + // D1 batch is transactional. Only the latest authoritative Linear snapshot + // can change visibility; milestone rows and visibility commit together. + const update = statement( + `UPDATE feedback SET status = ?, source_updated_at = ?, updated_at = ?, + linear_identifier = ?, linear_synced = 1 + WHERE id = ? AND source_updated_at < ? AND deleted_at IS NULL`, + state.status, + state.version, + now, + state.identifier, + id, + state.version, + ); + const milestone = (kind, enabled) => + statement( + `INSERT INTO feedback_mail (id, feedback_id, kind, recipient, title, created_at) + SELECT id || ':' || ?, id, ?, email, title, ? FROM feedback + WHERE id = ? AND source_updated_at = ? AND deleted_at IS NULL + AND status IN ('pending', 'review', 'done') AND ? = 1 + ON CONFLICT(feedback_id, kind) DO NOTHING`, + kind, + kind, + now, + id, + state.version, + enabled ? 1 : 0, + ); + await db.batch([ + update, + milestone("approved", PUBLIC_STATUSES.includes(state.status)), + milestone("progress", state.inProgress), + ]); + } + + return { + create, + findById, + listPublic, + applyModeration, + async remove(id, now = Date.now()) { + await statement( + "UPDATE feedback SET status = 'hidden', deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL", + now, + now, + id, + ).run(); + }, + async pendingSync() { + const { results } = await statement( + `SELECT * FROM feedback WHERE deleted_at IS NULL + ORDER BY checked_at, linear_synced, created_at LIMIT ?`, + SYNC_BATCH_SIZE, + ).all(); + return results; + }, + async checked(id, now) { + await statement("UPDATE feedback SET checked_at = ? WHERE id = ?", now, id).run(); + }, + async synced(id) { + await statement("UPDATE feedback SET linear_synced = 1 WHERE id = ?", id).run(); + }, + async requestSync(id, reachedProgress = false, now = Date.now()) { + // Preserve intermediate In Progress events even if the next poll already + // observes Done. Visibility still comes from the latest Linear snapshot. + const milestone = (kind) => + statement( + `INSERT INTO feedback_mail (id, feedback_id, kind, recipient, title, created_at) + SELECT id || ':' || ?, id, ?, email, title, ? FROM feedback + WHERE id = ? AND deleted_at IS NULL AND ? = 1 + ON CONFLICT(feedback_id, kind) DO NOTHING`, + kind, + kind, + now, + id, + reachedProgress ? 1 : 0, + ); + await db.batch([ + statement("UPDATE feedback SET checked_at = 0 WHERE id = ?", id), + milestone("approved"), + milestone("progress"), + ]); + }, + async acquireLease(now) { + const token = crypto.randomUUID(); + const row = await statement( + `UPDATE feedback_jobs SET lease_token = ?, lease_until = ? + WHERE name = 'sync' AND lease_until < ? RETURNING lease_token`, + token, + now + LEASE_MS, + now, + ).first(); + return row?.lease_token ?? null; + }, + async releaseLease(token) { + await statement( + "UPDATE feedback_jobs SET lease_until = 0 WHERE name = 'sync' AND lease_token = ?", + token, + ).run(); + }, + async pendingMail(now) { + const { results } = await statement( + `SELECT m.* FROM feedback_mail m JOIN feedback f ON f.id = m.feedback_id + WHERE m.sent_at IS NULL AND m.needs_review = 0 AND m.next_attempt_at <= ? + AND f.deleted_at IS NULL AND f.status IN ('pending', 'review', 'done') + ORDER BY m.created_at, m.kind LIMIT ?`, + now, + SYNC_BATCH_SIZE, + ).all(); + return results; + }, + async startMail(id, now) { + return statement( + `UPDATE feedback_mail SET first_attempt_at = COALESCE(first_attempt_at, ?), next_attempt_at = ? + WHERE id = ? AND sent_at IS NULL AND EXISTS ( + SELECT 1 FROM feedback f WHERE f.id = feedback_mail.feedback_id + AND f.deleted_at IS NULL AND f.status IN ('pending', 'review', 'done') + ) RETURNING *`, + now, + now + 60_000, + id, + ).first(); + }, + async mailSent(id, now) { + await statement("UPDATE feedback_mail SET sent_at = ? WHERE id = ?", now, id).run(); + }, + async mailNeedsReview(id) { + await statement("UPDATE feedback_mail SET needs_review = 1 WHERE id = ?", id).run(); + }, + }; +} diff --git a/backend/src/feedback-routes.mjs b/backend/src/feedback-routes.mjs index 3e0bfe03..79e05b9b 100644 --- a/backend/src/feedback-routes.mjs +++ b/backend/src/feedback-routes.mjs @@ -1,102 +1,98 @@ +import { authenticateFeedback } from "./feedback-auth.mjs"; import { readFeedback } from "./feedback-payload.mjs"; -import { createFeedbackIssue, listFeedbackBoard } from "./linear-feedback.mjs"; +import { createFeedbackRepository, parseBoardCursor } from "./feedback-repository.mjs"; import { PayloadError } from "./payload.mjs"; export const FEEDBACK_PATH = "/v1/feedback"; +export const FEEDBACK_CONFIG_PATH = `${FEEDBACK_PATH}/config`; const ALLOWED_ORIGINS = [ "https://deck.spacevibe.dev", - // `npm run prototype:landing` serves the page from here. "http://127.0.0.1:5173", "http://localhost:5173", ]; const METHODS = "GET, POST, OPTIONS"; -const BOARD_MAX_AGE_SECONDS = 60; -// A synthetic key: one board for every visitor, whatever their origin. -const BOARD_CACHE_KEY = "https://api.deck.spacevibe.dev/v1/feedback"; - const HEADERS = { "cache-control": "no-store", "x-content-type-options": "nosniff" }; -function corsHeaders(request) { +function headersFor(request) { const origin = request.headers.get("origin"); - return ALLOWED_ORIGINS.includes(origin) - ? { "access-control-allow-origin": origin, vary: "origin" } - : { vary: "origin" }; + return { + ...HEADERS, + vary: "origin", + ...(ALLOWED_ORIGINS.includes(origin) ? { "access-control-allow-origin": origin } : {}), + }; } -/** - * The board body is cached without CORS headers and wrapped per request, so a - * response built for one origin is never replayed to another. - */ -async function readBoard(env) { - const cache = globalThis.caches?.default; - const key = new Request(BOARD_CACHE_KEY); - const cached = await cache?.match(key); - if (cached) return cached.text(); - const body = JSON.stringify({ items: await listFeedbackBoard(env) }); - await cache?.put( - key, - new Response(body, { - headers: { - "content-type": "application/json", - "cache-control": `public, max-age=${BOARD_MAX_AGE_SECONDS}`, - }, - }), +function json(body, headers, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { ...headers, "content-type": "application/json" }, + }); +} + +export function feedbackConfigured(env) { + return Boolean( + env.FEEDBACK_SYNC_ENABLED === "true" && + env.GOOGLE_CLIENT_ID && + env.LINEAR_API_KEY && + env.LINEAR_WEBHOOK_SECRET && + env.FEEDBACK_PROGRESS_STATE_ID && + env.RESEND_API_KEY && + env.FEEDBACK_EMAIL_FROM && + env.DB, ); - return body; } -async function board(env, headers) { - try { - return new Response(await readBoard(env), { - status: 200, +export async function handleFeedback(request, env) { + const headers = headersFor(request); + const url = new URL(request.url); + if (request.method === "OPTIONS") + return new Response(null, { + status: 204, headers: { ...headers, - "content-type": "application/json", - "cache-control": `public, max-age=${BOARD_MAX_AGE_SECONDS}`, + "access-control-allow-methods": METHODS, + "access-control-allow-headers": "content-type, authorization", + "access-control-max-age": "86400", }, }); - } catch { - // Never log a Linear error; it can echo issue content. - return new Response(null, { status: 503, headers }); - } -} - -async function submit(request, env, headers) { try { - const feedback = await readFeedback(request); - // A bot sees success and moves on; nothing reaches Linear or the limiter. - if (feedback.spam) return new Response(null, { status: 204, headers }); - // A shared per-location budget bounds writes without reading an IP address. - const limit = await env.FEEDBACK_LIMITER.limit({ key: "feedback" }); - if (!limit.success) return new Response(null, { status: 429, headers }); - await createFeedbackIssue(env, feedback); - return new Response(null, { status: 204, headers }); - } catch (error) { - // Never log the submission or a Linear error. Body errors are the sender's; - // anything else is an infrastructure failure worth retrying. - const status = error instanceof PayloadError ? error.status : 503; - return new Response(null, { status, headers }); - } -} - -export function handleFeedback(request, env) { - const headers = { ...HEADERS, ...corsHeaders(request) }; - switch (request.method) { - case "OPTIONS": - return new Response(null, { - status: 204, - headers: { - ...headers, - "access-control-allow-methods": METHODS, - "access-control-allow-headers": "content-type", - "access-control-max-age": "86400", + if (url.pathname === FEEDBACK_CONFIG_PATH && request.method === "GET") { + return json( + { + googleClientId: env.GOOGLE_CLIENT_ID || null, + submissionsOpen: env.FEEDBACK_SUBMISSIONS_OPEN === "true" && feedbackConfigured(env), }, - }); - case "GET": - return board(env, headers); - case "POST": - return submit(request, env, headers); - default: + headers, + ); + } + if (url.pathname !== FEEDBACK_PATH) return new Response(null, { status: 405, headers }); + const repository = createFeedbackRepository(env.DB); + if (request.method === "GET") { + return json( + await repository.listPublic(parseBoardCursor(url.searchParams.get("cursor"))), + headers, + ); + } + if (request.method !== "POST") return new Response(null, { status: 405, headers: { ...headers, allow: METHODS } }); + if (!ALLOWED_ORIGINS.includes(request.headers.get("origin"))) throw new PayloadError(403); + if (env.FEEDBACK_SUBMISSIONS_OPEN !== "true" || !feedbackConfigured(env)) + throw new PayloadError(503); + const feedback = await readFeedback(request); + // Never acknowledge a discarded submission as saved. + if (feedback.spam) throw new PayloadError(400); + const preAuth = await env.FEEDBACK_LIMITER.limit({ key: "feedback-auth" }); + if (!preAuth.success) throw new PayloadError(429); + const user = await authenticateFeedback(request, env); + const limit = await env.FEEDBACK_LIMITER.limit({ key: `feedback-user:${user.sub}` }); + if (!limit.success) throw new PayloadError(429); + const id = await repository.create(feedback, user); + return json({ id, status: "private" }, headers, 201); + } catch (error) { + // Do not echo provider/SQL failures, which can contain private content. + return new Response(null, { + status: error instanceof PayloadError ? error.status : 503, + headers, + }); } } diff --git a/backend/src/feedback-sync.mjs b/backend/src/feedback-sync.mjs new file mode 100644 index 00000000..00f7cc16 --- /dev/null +++ b/backend/src/feedback-sync.mjs @@ -0,0 +1,34 @@ +import { createFeedbackIssue, readFeedbackState } from "./linear-feedback.mjs"; +import { createFeedbackRepository } from "./feedback-repository.mjs"; +import { deliverFeedbackMail } from "./feedback-mail.mjs"; + +export const FEEDBACK_SYNC_CRON = "* * * * *"; + +export async function syncFeedback(env, now = Date.now()) { + // Separate from intake: closing new submissions must not stop moderation or mail. + if (env.FEEDBACK_SYNC_ENABLED !== "true") return; + const repository = createFeedbackRepository(env.DB); + const lease = await repository.acquireLease(now); + if (!lease) return; + let failed = 0; + try { + const rows = await repository.pendingSync(); + for (const row of rows) { + // Rotate failures to the back as well, so a broken issue cannot starve others. + await repository.checked(row.id, now); + try { + if (!row.linear_synced) { + await createFeedbackIssue(env, row); + await repository.synced(row.id); + } + await repository.applyModeration(row.id, await readFeedbackState(env, row.id), now); + } catch { + failed += 1; + } + } + failed += await deliverFeedbackMail(env, repository, now); + } finally { + await repository.releaseLease(lease); + } + if (failed) throw new Error(`Feedback background jobs failed: ${failed}`); +} diff --git a/backend/src/feedback-webhook.mjs b/backend/src/feedback-webhook.mjs new file mode 100644 index 00000000..317de560 --- /dev/null +++ b/backend/src/feedback-webhook.mjs @@ -0,0 +1,86 @@ +import { createFeedbackRepository } from "./feedback-repository.mjs"; +import { PayloadError, UUID_V4 } from "./payload.mjs"; + +export const FEEDBACK_WEBHOOK_PATH = "/v1/feedback/linear-webhook"; +const BODY_LIMIT = 128 * 1024; +const MAX_AGE_MS = 60_000; +const HEADERS = { "cache-control": "no-store", "x-content-type-options": "nosniff" }; + +async function readSignedEvent(request, secret) { + const signature = request.headers.get("linear-signature") ?? ""; + if (!/^[a-f0-9]{64}$/i.test(signature)) throw new PayloadError(401); + if (!request.body) throw new PayloadError(400); + const reader = request.body.getReader(); + const chunks = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > BODY_LIMIT) { + await reader.cancel(); + throw new PayloadError(413); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const raw = new Uint8Array(await new Blob(chunks).arrayBuffer()); + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["verify"], + ); + const bytes = Uint8Array.from(signature.match(/../g), (hex) => parseInt(hex, 16)); + if (!(await crypto.subtle.verify("HMAC", key, bytes, raw))) throw new PayloadError(401); + let event; + try { + event = JSON.parse(new TextDecoder().decode(raw)); + } catch { + throw new PayloadError(400); + } + if ( + !event || + !Number.isSafeInteger(event.webhookTimestamp) || + Math.abs(Date.now() - event.webhookTimestamp) > MAX_AGE_MS + ) { + throw new PayloadError(401); + } + return event; +} + +export async function handleFeedbackWebhook(request, env) { + if (request.method !== "POST") return new Response(null, { status: 405, headers: HEADERS }); + try { + if (!env.LINEAR_WEBHOOK_SECRET) throw new Error("Webhook not configured"); + const event = await readSignedEvent(request, env.LINEAR_WEBHOOK_SECRET); + if (event.type === "Issue" && ["create", "update", "remove"].includes(event.action)) { + if (!UUID_V4.test(event.data?.id ?? "")) throw new PayloadError(400); + const repository = createFeedbackRepository(env.DB); + // Ignore all issues except ids created by this feedback repository. + const row = await repository.findById(event.data.id); + if (row && event.action === "remove") await repository.remove(row.id); + else if (row) { + const reachedProgress = + event.data.stateId === env.FEEDBACK_PROGRESS_STATE_ID && + event.data.teamId === env.FEEDBACK_TEAM_ID && + !event.data.archivedAt && + Array.isArray(event.data.labelIds) && + event.data.labelIds.includes(env.FEEDBACK_LABEL_ID); + await repository.requestSync(row.id, reachedProgress); + } + } + // Persist the refresh request before acknowledgment. No network round trip + // here: Linear requires an acknowledgment within five seconds. + return new Response(null, { status: 200, headers: HEADERS }); + } catch (error) { + return new Response(null, { + status: error instanceof PayloadError ? error.status : 503, + headers: HEADERS, + }); + } +} diff --git a/backend/src/feedback.test.mjs b/backend/src/feedback.test.mjs index 34101a96..96b1d2e6 100644 --- a/backend/src/feedback.test.mjs +++ b/backend/src/feedback.test.mjs @@ -1,337 +1,573 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; import { test } from "node:test"; -import { FEEDBACK_PROBE_CRON } from "./linear-feedback.mjs"; +import { createFeedbackRepository, parseBoardCursor, PAGE_SIZE } from "./feedback-repository.mjs"; +import { syncFeedback } from "./feedback-sync.mjs"; +import { createFeedbackIssue, describeFeedback, probeFeedbackConfig } from "./linear-feedback.mjs"; +import { authenticateFeedback } from "./feedback-auth.mjs"; import worker from "./worker.mjs"; -const URL_FEEDBACK = "https://api.deck.spacevibe.dev/v1/feedback"; const ORIGIN = "https://deck.spacevibe.dev"; -const DRAFT_ID = "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed"; -const BUG_LABEL = "ed9079dd-f534-4092-98ec-241335972834"; -const FEATURE_LABEL = "404554e5-9175-474e-b211-1a31ebde49e2"; -const IMPROVEMENT_LABEL = "ad32e36f-0027-41db-9e49-ad99864dc7cc"; -const NEEDS_DECISION_LABEL = "0968e82c-0db3-48ce-83c5-fe4afd04a5b3"; -const feedback = { - title: "Split panes lose focus", - body: "After closing a pane the terminal\nno longer takes keys.", +const API = "https://api.deck.spacevibe.dev/v1/feedback"; +const user = { sub: "google-subject", email: "reporter@gmail.com" }; +const input = { + id: "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed", + title: "Split pane focus", + body: "Steps\nDetails", category: "bug", website: "", }; +const keys = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], +); +const jwk = { + ...(await crypto.subtle.exportKey("jwk", keys.publicKey)), + kid: "test-key", + alg: "RS256", + use: "sig", +}; +const json = (body, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + +function database(t) { + const sqlite = new DatabaseSync(":memory:"); + sqlite.exec(readFileSync(new URL("../migrations/0003-feedback.sql", import.meta.url), "utf8")); + t.after(() => sqlite.close()); + const prepare = (sql) => ({ + bind: (...args) => ({ + run: async () => ({ meta: { changes: sqlite.prepare(sql).run(...args).changes } }), + first: async () => sqlite.prepare(sql).get(...args) ?? null, + all: async () => ({ results: sqlite.prepare(sql).all(...args) }), + }), + }); + const binding = { + prepare, + batch: async (statements) => { + sqlite.exec("BEGIN"); + try { + const result = await Promise.all(statements.map((s) => s.run())); + sqlite.exec("COMMIT"); + return result; + } catch (error) { + sqlite.exec("ROLLBACK"); + throw error; + } + }, + }; + return { sqlite, binding, repository: createFeedbackRepository(binding) }; +} -function environment(overrides = {}) { +function env(db, overrides = {}) { return { - LINEAR_API_KEY: "lin_api_test", - FEEDBACK_TEAM_ID: "team-id", - FEEDBACK_LABEL_ID: "feedback-label", - FEEDBACK_BACKLOG_STATE_ID: "backlog-state", + DB: db, + GOOGLE_CLIENT_ID: "test.apps.googleusercontent.com", + LINEAR_API_KEY: "test-linear", + LINEAR_WEBHOOK_SECRET: "test-webhook-signing-secret", + RESEND_API_KEY: "test-mail", + FEEDBACK_EMAIL_FROM: "Deck ", + FEEDBACK_TEAM_ID: "team", + FEEDBACK_LABEL_ID: "label", + FEEDBACK_BACKLOG_STATE_ID: "backlog", + FEEDBACK_PROGRESS_STATE_ID: "progress", + FEEDBACK_SUBMISSIONS_OPEN: "true", + FEEDBACK_SYNC_ENABLED: "true", FEEDBACK_LIMITER: { limit: async () => ({ success: true }) }, ...overrides, }; } -function submit(body = feedback, init = {}) { - return new Request(URL_FEEDBACK, { +async function token(overrides = {}) { + const now = Math.floor(Date.now() / 1000); + const header = Buffer.from(JSON.stringify({ alg: "RS256", kid: "test-key" })).toString( + "base64url", + ); + const claims = Buffer.from( + JSON.stringify({ + iss: "https://accounts.google.com", + aud: "test.apps.googleusercontent.com", + sub: user.sub, + email: user.email, + email_verified: true, + iat: now, + exp: now + 3600, + ...overrides, + }), + ).toString("base64url"); + const signed = `${header}.${claims}`; + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + keys.privateKey, + new TextEncoder().encode(signed), + ); + return `${signed}.${Buffer.from(signature).toString("base64url")}`; +} + +function submit(body, credential, origin = ORIGIN) { + return new Request(API, { method: "POST", - headers: { "content-type": "application/json", origin: ORIGIN }, - body: typeof body === "string" ? body : JSON.stringify(body), - ...init, + headers: { "content-type": "application/json", authorization: `Bearer ${credential}`, origin }, + body: JSON.stringify(body), }); } -/** Record every Linear call and answer with `reply`. */ -function stubLinear(t, reply) { +function providers(t, respond = () => json({}, 503)) { const calls = []; t.mock.method(globalThis, "fetch", async (url, init) => { - calls.push({ url, init, request: JSON.parse(init.body) }); - return reply(calls.at(-1)); + if (url === "https://www.googleapis.com/oauth2/v3/certs") return json({ keys: [jwk] }); + const call = { url, init, body: JSON.parse(init.body) }; + calls.push(call); + return respond(call); }); return calls; } -const json = (value, status = 200) => - new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); -const created = () => json({ data: { issueCreate: { success: true } } }); +const state = (status = "pending", version = 100, inProgress = false) => ({ + status, + version, + inProgress, + identifier: "DECK-999", +}); -test("a honeypot submission answers 204 without touching Linear or the limiter", async (t) => { - const calls = stubLinear(t, created); - let limited = 0; - const env = environment({ - FEEDBACK_LIMITER: { limit: async () => (limited++, { success: true }) }, - }); - const response = await worker.fetch(submit({ ...feedback, website: "https://spam" }), env); - assert.equal(response.status, 204); +test("POST acknowledges only persisted private feedback and does not call Linear", async (t) => { + const { binding, sqlite } = database(t); + const calls = providers(t); + const response = await worker.fetch(submit(input, await token()), env(binding)); + assert.equal(response.status, 201); + const receipt = await response.json(); + assert.equal(receipt.status, "private"); + const row = sqlite.prepare("SELECT * FROM feedback").get(); + assert.equal(row.id, receipt.id); + assert.notEqual(row.id, input.id); + assert.equal(row.email, user.email); + assert.equal(row.status, "private"); assert.equal(calls.length, 0); - assert.equal(limited, 0); + assert.deepEqual((await (await worker.fetch(new Request(API), env(binding))).json()).items, []); }); -test("malformed submissions are terminal and never reach Linear", async (t) => { - const calls = stubLinear(t, created); - for (const body of [ - { ...feedback, email: "someone@example.com" }, - { ...feedback, title: "x".repeat(121) }, - { ...feedback, title: " \n\t " }, - { ...feedback, body: "y".repeat(2001) }, - { ...feedback, category: "praise" }, - { ...feedback, title: 42 }, - { ...feedback, id: "not-a-uuid" }, - { body: "no title", category: "idea" }, - "{", +test("D1 rejection returns 503 and never reports success", async (t) => { + providers(t); + const db = { + prepare() { + throw new Error("storage down"); + }, + }; + assert.equal((await worker.fetch(submit(input, await token()), env(db))).status, 503); +}); + +test("authentication checks signature, audience, issuer, expiry and verified Google email", async (t) => { + providers(t); + assert.deepEqual(await authenticateFeedback(submit(input, await token()), env()), user); + for (const claims of [ + { aud: "other" }, + { iss: "evil" }, + { exp: 1 }, + { email_verified: false }, + { sub: "" }, + { email: "user@third-party.example" }, + { iat: 9999999999 }, ]) { - assert.equal((await worker.fetch(submit(body), environment())).status, 400); + await assert.rejects(authenticateFeedback(submit(input, await token(claims)), env()), { + status: 401, + }); + } + const valid = await token(); + const [header, body, signature] = valid.split("."); + const tampered = `${header}.${Buffer.from(JSON.stringify({ sub: "attacker" })).toString("base64url")}.${signature}`; + await assert.rejects(authenticateFeedback(submit(input, tampered), env()), { status: 401 }); + for (const bad of ["", "abc", `${header}.${body}.!`, `e30.${body}.${signature}`]) { + await assert.rejects(authenticateFeedback(submit(input, bad), env()), { status: 401 }); } - const wrongType = submit(feedback, { headers: { "content-type": "text/plain" } }); - assert.equal((await worker.fetch(wrongType, environment())).status, 400); - assert.equal(calls.length, 0); }); -test("the body cap is measured in bytes and fits 2,000 characters of any script", async (t) => { - const calls = stubLinear(t, created); - const cjk = await worker.fetch(submit({ ...feedback, body: "漢".repeat(2000) }), environment()); - assert.equal(cjk.status, 204); - const oversized = await worker.fetch( - submit({ ...feedback, website: "x".repeat(17000) }), - environment(), +test("invalid input, honeypot and foreign origins never save; closed intake leaves public reads", async (t) => { + const { binding, sqlite, repository } = database(t); + providers(t); + const credential = await token(); + for (const value of [ + { ...input, id: "" }, + { ...input, title: "x" }, + { ...input, body: "x".repeat(2001) }, + { ...input, email: "spoof@gmail.com" }, + { ...input, category: "bad" }, + { ...input, website: "spam" }, + ]) { + assert.equal((await worker.fetch(submit(value, credential), env(binding))).status, 400); + } + assert.equal( + (await worker.fetch(submit(input, credential, "https://evil.example"), env(binding))).status, + 403, + ); + assert.equal((await worker.fetch(submit(input, ""), env(binding))).status, 401); + assert.equal( + ( + await worker.fetch( + submit(input, credential), + env(binding, { FEEDBACK_LIMITER: { limit: async () => ({ success: false }) } }), + ) + ).status, + 429, ); - assert.equal(oversized.status, 413); - assert.equal(calls.length, 1); + assert.equal(sqlite.prepare("SELECT count(*) n FROM feedback").get().n, 0); + const id = await repository.create(input, user); + await repository.applyModeration(id, state()); + const closed = env(binding, { FEEDBACK_SUBMISSIONS_OPEN: "false" }); + assert.equal((await worker.fetch(submit(input, credential), closed)).status, 503); + const page = await (await worker.fetch(new Request(API), closed)).json(); + assert.equal(page.items.length, 1); + assert.equal(page.items[0].description, input.body); + assert.doesNotMatch(JSON.stringify(page), /google-subject|reporter@gmail|draft_id/); }); -test("the limiter answers a retryable 429 before Linear", async (t) => { - const calls = stubLinear(t, () => json({})); - const env = environment({ FEEDBACK_LIMITER: { limit: async () => ({ success: false }) } }); - assert.equal((await worker.fetch(submit(), env)).status, 429); - assert.equal(calls.length, 0); +test("retries are immutable and scoped to Google subject, not user-supplied public ids", async (t) => { + const { repository, sqlite } = database(t); + const id = await repository.create(input, user); + assert.equal(await repository.create(input, user), id); + await assert.rejects(repository.create({ ...input, body: "Changed" }, user), { status: 409 }); + assert.notEqual(await repository.create(input, { ...user, sub: "another-user" }), id); + assert.equal(sqlite.prepare("SELECT count(*) n FROM feedback").get().n, 2); }); -test("a valid submission creates one Backlog issue in the agreed format", async (t) => { - const calls = stubLinear(t, created); - const response = await worker.fetch( - submit({ ...feedback, title: " Split\tpanes \n lose focus ", id: DRAFT_ID }), - environment(), - ); - assert.equal(response.status, 204); - assert.equal(response.headers.get("access-control-allow-origin"), ORIGIN); - assert.equal(calls.length, 1); - assert.equal(calls[0].url, "https://api.linear.app/graphql"); - assert.equal(calls[0].init.headers.authorization, "lin_api_test"); - assert.ok(calls[0].init.signal instanceof AbortSignal); - const { input } = calls[0].request.variables; - assert.equal(input.id, DRAFT_ID); - assert.equal(input.teamId, "team-id"); - assert.equal(input.stateId, "backlog-state"); - assert.equal(input.title, "Split panes lose focus"); - assert.deepEqual(input.labelIds, ["feedback-label", BUG_LABEL]); - assert.equal( - input.description, - [ - "## User report", - "", - "```text", - "After closing a pane the terminal", - "no longer takes keys.", - "```", - "", - "## Submission", - "", - "- Category: Bug", - "- Source: deck.spacevibe.dev/feedback", - ].join("\n"), - ); +test("public pagination reaches old Done items with equal timestamps and no private rows", async (t) => { + const { repository } = database(t); + for (let index = 0; index < PAGE_SIZE + 4; index++) { + const id = await repository.create({ ...input, id: crypto.randomUUID() }, user, 1); + await repository.applyModeration(id, state("done")); + } + await repository.create({ ...input, id: crypto.randomUUID() }, user); + const first = await repository.listPublic(null); + const last = await repository.listPublic(parseBoardCursor(first.nextCursor)); + assert.equal(first.items.length, PAGE_SIZE); + assert.equal(last.items.length, 4); + assert.equal(last.nextCursor, null); + assert.equal(new Set([...first.items, ...last.items].map((x) => x.id)).size, PAGE_SIZE + 4); + assert.throws(() => parseBoardCursor("0:bad"), { status: 400 }); }); -test("visitor text stays inert: the fence outgrows backticks and linear.app links are defused", async (t) => { - const calls = stubLinear(t, created); - const body = "See ```` here ![x](https://evil.example/p.png) https://Linear.app/mxrsv/profiles/x"; - await worker.fetch(submit({ ...feedback, body }), environment()); - await worker.fetch(submit({ ...feedback, body: "" }), environment()); - const [fenced, empty] = calls.map((call) => call.request.variables.input.description); - assert.match(fenced, /\n`````text\nSee ```` here !\[x\]\(https:\/\/evil\.example\/p\.png\) /); - assert.match(fenced, /https:\/\/linear\[\.\]app\/mxrsv\/profiles\/x\n`````\n/); - assert.match(empty, /^## User report\n\nNo details were given\.\n/); +test("moderation is monotonic, milestones are atomic and duplicates cannot send again", async (t) => { + const { repository, sqlite } = database(t); + const id = await repository.create(input, user); + await repository.applyModeration(id, state("pending", 100)); + await repository.applyModeration(id, state("review", 200, true)); + await repository.applyModeration(id, state("review", 200, true)); + await repository.applyModeration(id, state("hidden", 150)); + assert.equal((await repository.findById(id)).status, "review"); + assert.equal(sqlite.prepare("SELECT count(*) n FROM feedback_mail").get().n, 2); + await repository.applyModeration(id, state("hidden", 300)); + assert.equal((await repository.listPublic(null)).items.length, 0); + assert.equal((await repository.pendingMail(Date.now())).length, 0); + await repository.remove(id); + await repository.applyModeration(id, state("pending", 400)); + assert.equal((await repository.findById(id)).status, "hidden"); }); -test("ideas carry the Feature label; other asks for a decision", async (t) => { - const calls = stubLinear(t, created); - await worker.fetch(submit({ ...feedback, category: "idea" }), environment()); - await worker.fetch(submit({ ...feedback, category: "other" }), environment()); - assert.deepEqual(calls[0].request.variables.input.labelIds, ["feedback-label", FEATURE_LABEL]); - assert.deepEqual(calls[1].request.variables.input.labelIds, [ - "feedback-label", - NEEDS_DECISION_LABEL, - ]); - assert.match(calls[1].request.variables.input.description, /- Category: Other/); +test("failure creating a milestone rolls back publication", async (t) => { + const { repository, sqlite } = database(t); + const id = await repository.create(input, user); + sqlite.exec( + "CREATE TRIGGER fail_mail BEFORE INSERT ON feedback_mail BEGIN SELECT RAISE(ABORT, 'mail unavailable'); END;", + ); + await assert.rejects(repository.applyModeration(id, state())); + assert.equal((await repository.findById(id)).status, "private"); }); -test("every Linear failure is a retryable 503", async (t) => { - for (const reply of [ - () => json({ errors: [{ message: "bad" }] }), - () => json({ data: { issueCreate: { success: false } } }), - () => json({}, 500), - () => json({ errors: [{ extensions: { code: "RATELIMITED" } }] }, 400), - () => { - throw new TypeError("network down"); +function linearSnapshot(id, type = "unstarted", version = 100) { + return { + data: { + issues: { + nodes: [ + { + id, + identifier: "DECK-999", + updatedAt: new Date(version).toISOString(), + archivedAt: null, + team: { id: "team" }, + state: { type, id: type === "started" ? "progress" : "todo" }, + labels: { nodes: [{ id: "label" }] }, + }, + ], + }, }, - ]) { - t.mock.restoreAll(); - stubLinear(t, reply); - assert.equal((await worker.fetch(submit(), environment())).status, 503); - } + }; +} + +test("Linear outage keeps durable intake; retry creates one linked issue and sends milestones once", async (t) => { + const { repository, binding, sqlite } = database(t); + const id = await repository.create(input, user); + providers(t); + await assert.rejects(syncFeedback(env(binding)), /background jobs failed/); + assert.equal((await repository.findById(id)).linear_synced, 0); t.mock.restoreAll(); - const calls = stubLinear(t, () => json({})); - const missingKey = environment({ LINEAR_API_KEY: undefined }); - assert.equal((await worker.fetch(submit(), missingKey)).status, 503); + const calls = providers(t, ({ url, body }) => + url.includes("resend") + ? json({ id: "email-receipt" }) + : body.query.includes("issueCreate") + ? json({ data: { issueCreate: { success: true } } }) + : json(linearSnapshot(id)), + ); + await syncFeedback(env(binding)); + await syncFeedback(env(binding)); + assert.equal((await repository.findById(id)).status, "pending"); + assert.equal(calls.filter((c) => c.body.query?.includes("issueCreate")).length, 1); + const mail = calls.filter((c) => c.url.includes("resend")); + assert.equal(mail.length, 1); + assert.deepEqual(mail[0].body.to, [user.email]); + assert.equal(mail[0].init.headers["idempotency-key"], `${id}:approved`); + assert.equal(sqlite.prepare("SELECT sent_at FROM feedback_mail").get().sent_at > 0, true); +}); + +test("mail retries keep their key and stop before the provider's deduplication window expires", async (t) => { + const { repository, binding, sqlite } = database(t); + const id = await repository.create(input, user); + await repository.synced(id); + await repository.applyModeration(id, state()); + const started = Date.now(); + const calls = providers(t, ({ url }) => + url.includes("resend") ? json({}, 503) : json(linearSnapshot(id)), + ); + await assert.rejects(syncFeedback(env(binding), started)); + await assert.rejects(syncFeedback(env(binding), started + 60_000)); + await assert.rejects(syncFeedback(env(binding), started + 24 * 60 * 60_000)); + const mail = calls.filter((c) => c.url.includes("resend")); + assert.equal(mail.length, 2); + assert.equal(mail[0].init.headers["idempotency-key"], mail[1].init.headers["idempotency-key"]); + assert.equal(sqlite.prepare("SELECT needs_review FROM feedback_mail").get().needs_review, 1); +}); + +test("concurrent scheduled jobs share a lease", async (t) => { + const { repository, binding } = database(t); + await repository.create(input, user); + const lease = await repository.acquireLease(Date.now()); + const calls = providers(t); + await syncFeedback(env(binding)); assert.equal(calls.length, 0); + await repository.releaseLease("wrong-token"); + assert.equal(await repository.acquireLease(Date.now()), null); + await repository.releaseLease(lease); + assert.ok(await repository.acquireLease(Date.now())); }); -test("a retry whose issue already landed succeeds instead of duplicating", async (t) => { - const calls = stubLinear(t, ({ request }) => { - if (request.query.includes("issueCreate")) throw new TypeError("answer lost"); - return json({ data: { issue: { id: request.variables.id } } }); +async function webhook(event, secret = "test-webhook-signing-secret") { + const raw = JSON.stringify({ + type: "Issue", + action: "update", + webhookTimestamp: Date.now(), + ...event, }); - const response = await worker.fetch(submit({ ...feedback, id: DRAFT_ID }), environment()); - assert.equal(response.status, 204); - assert.equal(calls.length, 2); - assert.deepEqual(calls[1].request.variables, { id: DRAFT_ID }); + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(raw)); + return new Request(`${API}/linear-webhook`, { + method: "POST", + headers: { "linear-signature": Buffer.from(signature).toString("hex") }, + body: raw, + }); +} - t.mock.restoreAll(); - const missing = stubLinear(t, ({ request }) => - request.query.includes("issueCreate") - ? json({}, 500) - : json({ errors: [{ message: "Entity not found" }] }), +test("signed webhooks persist refresh/delete, reject stale/forged events and ignore unrelated ids", async (t) => { + const { repository, binding } = database(t); + const id = await repository.create(input, user); + await repository.applyModeration(id, state()); + await repository.checked(id, 100); + const calls = providers(t); + assert.equal((await worker.fetch(await webhook({ data: { id } }), env(binding))).status, 200); + assert.equal((await repository.findById(id)).checked_at, 0); + assert.equal( + (await worker.fetch(await webhook({ data: { id }, action: "remove" }, "wrong"), env(binding))) + .status, + 401, ); assert.equal( - (await worker.fetch(submit({ ...feedback, id: DRAFT_ID }), environment())).status, - 503, + (await worker.fetch(await webhook({ data: { id }, webhookTimestamp: 1 }), env(binding))).status, + 401, ); - assert.equal(missing.length, 2); + assert.equal( + (await worker.fetch(await webhook({ data: { id: crypto.randomUUID() } }), env(binding))).status, + 200, + ); + assert.equal( + (await worker.fetch(await webhook({ data: { id }, action: "remove" }), env(binding))).status, + 200, + ); + assert.equal((await repository.listPublic(null)).items.length, 0); + assert.equal(calls.length, 0); }); -const issue = (identifier, type, updatedAt, labels = []) => ({ - identifier, - title: `${identifier} title`, - updatedAt, - state: { type }, - labels: { nodes: labels.map((id) => ({ id })) }, - description: "PRIVATE BODY TEXT", +test("Linear issue creation fences text, assigns type and recovers a lost response", async (t) => { + const description = describeFeedback({ body: "```` https://linear.app/a", category: "other" }); + assert.match(description, /`````text/); + assert.match(description, /linear\[\.\]app/); + const calls = providers(t, ({ body }) => + body.query.includes("issueCreate") + ? json({}, 503) + : json({ data: { issue: { id: input.id } } }), + ); + await createFeedbackIssue(env(), input); + assert.equal(calls.length, 2); + assert.equal(calls[0].body.variables.input.id, input.id); + assert.equal(calls[0].body.variables.input.stateId, "backlog"); }); -const boardReply = (open, done = []) => - json({ data: { open: { nodes: open }, done: { nodes: done } } }); - -function board(url = URL_FEEDBACK) { - return new Request(url, { headers: { origin: ORIGIN } }); -} - -test("the board asks only for published states and maps them by type", async (t) => { - const calls = stubLinear(t, () => - boardReply( - [ - issue("DECK-2", "unstarted", "2026-09-14T09:00:00.000Z", [BUG_LABEL]), - issue("DECK-3", "started", "2026-09-14T11:00:00.000Z", [IMPROVEMENT_LABEL]), - issue("DECK-4", "started", "2026-09-13T11:00:00.000Z"), - issue("DECK-7", "canceled", "2026-09-14T12:00:00.000Z"), - ], - [issue("DECK-6", "completed", "2026-09-11T11:00:00.000Z")], - ), +test("configuration stays closed when dependencies are missing and preflight allows bearer auth", async (t) => { + const { binding } = database(t); + const response = await worker.fetch( + new Request(`${API}/config`), + env(binding, { RESEND_API_KEY: undefined }), ); - const response = await worker.fetch(board(), environment()); - assert.equal(response.status, 200); - assert.equal(response.headers.get("cache-control"), "public, max-age=60"); - assert.equal(response.headers.get("access-control-allow-origin"), ORIGIN); - const text = await response.text(); - assert.doesNotMatch(text, /PRIVATE BODY TEXT|description/); - const { items } = JSON.parse(text); - assert.deepEqual( - items.map(({ id, status, category }) => [id, status, category]), - [ - ["DECK-3", "review", "idea"], - ["DECK-2", "pending", "bug"], - ["DECK-4", "review", "other"], - ["DECK-6", "done", "other"], - ], + assert.equal((await response.json()).submissionsOpen, false); + const preflight = await worker.fetch( + new Request(API, { method: "OPTIONS", headers: { origin: ORIGIN } }), + env(binding), ); - assert.deepEqual(Object.keys(items[0]).sort(), [ - "category", - "id", - "status", - "title", - "updatedAt", - ]); - const { query, variables } = calls[0].request; - assert.deepEqual(variables, { - team: "team-id", - label: "feedback-label", - types: [BUG_LABEL, FEATURE_LABEL, IMPROVEMENT_LABEL], - }); - assert.match(query, /state: \{ type: \{ in: \["unstarted", "started"\] \} \}/); - assert.match(query, /state: \{ type: \{ eq: "completed" \} \}/); - // No connection is left to Linear's 50-node default price. - assert.match(query, /labels\(first: 3,/); + assert.equal(preflight.headers.get("access-control-allow-origin"), ORIGIN); + assert.match(preflight.headers.get("access-control-allow-headers"), /authorization/); }); -test("the Done column keeps only the 30 most recent items", async (t) => { - const done = Array.from({ length: 40 }, (_, index) => - issue(`DECK-${index}`, "completed", new Date(Date.UTC(2026, 8, 1, index)).toISOString()), +test("probe rejects missing or archived Linear configuration", async (t) => { + providers(t, () => + json({ + data: { viewer: { id: "user" }, team: { id: "team" }, feedback: { archivedAt: "date" } }, + }), ); - stubLinear(t, () => boardReply([], done)); - const { items } = await (await worker.fetch(board(), environment())).json(); - assert.equal(items.length, 30); - assert.equal(items[0].id, "DECK-39"); - assert.equal(items.at(-1).id, "DECK-10"); + await assert.rejects(probeFeedbackConfig(env())); }); -test("a Linear failure on the board is a 503", async (t) => { - stubLinear(t, () => json({ errors: [{ message: "bad" }] })); - assert.equal((await worker.fetch(board(), environment())).status, 503); +test("a signed In Progress event followed by Done before polling still delivers both milestones", async (t) => { + const { repository, binding, sqlite } = database(t); + const id = await repository.create(input, user); + await repository.synced(id); + const data = { id, stateId: "progress", teamId: "team", labelIds: ["label"], archivedAt: null }; + assert.equal((await worker.fetch(await webhook({ data }), env(binding))).status, 200); + assert.equal((await worker.fetch(await webhook({ data }), env(binding))).status, 200); + assert.equal(sqlite.prepare("SELECT count(*) n FROM feedback_mail").get().n, 2); + assert.equal((await repository.listPublic(null)).items.length, 0); + const calls = providers(t, ({ url }) => + url.includes("resend") ? json({ id: "receipt" }) : json(linearSnapshot(id, "completed", 300)), + ); + await syncFeedback(env(binding)); + assert.equal((await repository.findById(id)).status, "done"); + assert.equal(calls.filter((c) => c.url.includes("resend")).length, 2); }); -const probe = { cron: FEEDBACK_PROBE_CRON, scheduledTime: 0 }; -const healthy = { - viewer: { id: "user" }, - team: { id: "team-id" }, - feedback: { archivedAt: null }, - bug: { archivedAt: null }, - feature: { archivedAt: null }, - decision: { archivedAt: null }, - backlog: { archivedAt: null }, -}; - -test("the hourly probe passes only while key, team, labels and Backlog all hold", async (t) => { - const calls = stubLinear(t, () => json({ data: healthy })); - await worker.scheduled(probe, environment()); - assert.equal(calls.length, 1); - assert.equal(calls[0].request.variables.state, "backlog-state"); - - for (const data of [ - { ...healthy, feedback: { archivedAt: "2026-09-15T00:00:00.000Z" } }, - { ...healthy, backlog: null }, - ]) { +test("actual Linear state mappings preserve moderation and never hide a missing snapshot", async (t) => { + const { repository, binding, sqlite } = database(t); + const id = await repository.create(input, user); + await repository.synced(id); + const variants = [ + ["backlog", {}, false], + ["unstarted", {}, true], + ["started", {}, true], + ["completed", {}, true], + ["canceled", {}, false], + ["unstarted", { archivedAt: new Date(900).toISOString() }, false], + [ + "completed", + { archivedAt: new Date(900).toISOString(), autoArchivedAt: new Date(900).toISOString() }, + true, + ], + ["unstarted", { labels: { nodes: [] } }, false], + ["unstarted", { team: { id: "other" } }, false], + ]; + for (const [index, [type, changes, visible]] of variants.entries()) { t.mock.restoreAll(); - stubLinear(t, () => json({ data })); - await assert.rejects(worker.scheduled(probe, environment()), /feedback|backlog/); + const snapshot = linearSnapshot(id, type, (index + 1) * 1000); + const changed = { + data: { issues: { nodes: [{ ...snapshot.data.issues.nodes[0], ...changes }] } }, + }; + providers(t, ({ url }) => (url.includes("resend") ? json({ id: "receipt" }) : json(changed))); + await syncFeedback(env(binding)); + assert.equal( + (await repository.listPublic(null)).items.length, + visible ? 1 : 0, + type + JSON.stringify(changes), + ); } + assert.equal(sqlite.prepare("SELECT count(*) n FROM feedback_mail").get().n, 2); + await repository.applyModeration(id, state("pending", 10000)); t.mock.restoreAll(); - stubLinear(t, () => json({}, 401)); - await assert.rejects(worker.scheduled(probe, environment())); - await assert.rejects(worker.scheduled(probe, environment({ LINEAR_API_KEY: undefined }))); + providers(t, () => json({ data: { issues: { nodes: [] } } })); + await assert.rejects(syncFeedback(env(binding))); + assert.equal((await repository.listPublic(null)).items.length, 1); }); -test("CORS: preflight answers the allowed origin; others get no allow-origin", async () => { - const preflight = await worker.fetch( - new Request(URL_FEEDBACK, { method: "OPTIONS", headers: { origin: "http://127.0.0.1:5173" } }), - environment(), +test("lost mail receipt and failed sent_at write retry the identical payload without early delivery", async (t) => { + const { repository, binding, sqlite } = database(t); + const id = await repository.create(input, user); + await repository.synced(id); + await repository.applyModeration(id, state()); + const now = Date.now(); + let requests = 0; + const calls = providers(t, ({ url }) => { + if (!url.includes("resend")) return json(linearSnapshot(id)); + requests++; + if (requests === 1) throw new Error("receipt lost after provider accepted"); + return json({ id: "same-receipt" }); + }); + await assert.rejects(syncFeedback(env(binding), now)); + await syncFeedback(env(binding), now + 30_000); + assert.equal(requests, 1); + sqlite.exec( + "CREATE TRIGGER fail_sent BEFORE UPDATE OF sent_at ON feedback_mail BEGIN SELECT RAISE(ABORT, 'write failed'); END;", + ); + await assert.rejects(syncFeedback(env(binding), now + 60_000)); + sqlite.exec("DROP TRIGGER fail_sent;"); + await syncFeedback(env(binding), now + 120_000); + await syncFeedback(env(binding), now + 180_000); + const mail = calls.filter((c) => c.url.includes("resend")); + assert.equal(mail.length, 3); + assert.deepEqual( + mail.map((c) => c.body), + [mail[0].body, mail[0].body, mail[0].body], ); - assert.equal(preflight.status, 204); - assert.equal(preflight.headers.get("access-control-allow-origin"), "http://127.0.0.1:5173"); - assert.equal(preflight.headers.get("access-control-allow-methods"), "GET, POST, OPTIONS"); - assert.equal(preflight.headers.get("access-control-allow-headers"), "content-type"); - assert.equal(preflight.headers.get("access-control-max-age"), "86400"); - assert.equal(preflight.headers.get("vary"), "origin"); - const foreign = await worker.fetch( - new Request(URL_FEEDBACK, { method: "OPTIONS", headers: { origin: "https://evil.example" } }), - environment(), + assert.equal(new Set(mail.map((c) => c.init.headers["idempotency-key"])).size, 1); +}); + +test("webhook write failures do not acknowledge removal or refresh", async (t) => { + const { repository, binding, sqlite } = database(t); + const id = await repository.create(input, user); + await repository.applyModeration(id, state()); + sqlite.exec( + "CREATE TRIGGER fail_refresh BEFORE UPDATE ON feedback BEGIN SELECT RAISE(ABORT, 'write failed'); END;", + ); + for (const action of ["update", "remove"]) { + assert.equal( + (await worker.fetch(await webhook({ action, data: { id } }), env(binding))).status, + 503, + ); + } + assert.equal((await repository.listPublic(null)).items.length, 1); + sqlite.exec("DROP TRIGGER fail_refresh;"); + assert.equal( + (await worker.fetch(await webhook({ action: "remove", data: { id } }), env(binding))).status, + 200, + ); + assert.equal((await repository.listPublic(null)).items.length, 0); +}); + +test("body limits allow full CJK details and reject oversized streams", async (t) => { + const { binding } = database(t); + providers(t); + const credential = await token(); + assert.equal( + (await worker.fetch(submit({ ...input, body: "漢".repeat(2000) }, credential), env(binding))) + .status, + 201, + ); + assert.equal( + (await worker.fetch(submit({ ...input, website: "x".repeat(17000) }, credential), env(binding))) + .status, + 413, ); - assert.equal(foreign.headers.get("access-control-allow-origin"), null); - const put = await worker.fetch(new Request(URL_FEEDBACK, { method: "PUT" }), environment()); - assert.equal(put.status, 405); - assert.equal(put.headers.get("allow"), "GET, POST, OPTIONS"); }); diff --git a/backend/src/linear-feedback.mjs b/backend/src/linear-feedback.mjs index b2c5b741..c9bc56de 100644 --- a/backend/src/linear-feedback.mjs +++ b/backend/src/linear-feedback.mjs @@ -1,6 +1,6 @@ /** - * Linear is the only store for public feedback (DECK-101): the Worker creates - * issues and reads their state back; the owner moves them in Linear itself. + * Linear is the moderation surface. D1 owns the original feedback and retries + * delivery here; the owner controls publication by moving the linked issue. */ const LINEAR_ENDPOINT = "https://api.linear.app/graphql"; // A hung Linear call must not hang the visitor; the landing keeps the draft. @@ -15,52 +15,24 @@ export const FEEDBACK_PROBE_CRON = "17 * * * *"; /** Workspace labels in SpaceVibe-Deck. */ const BUG_LABEL = "ed9079dd-f534-4092-98ec-241335972834"; const FEATURE_LABEL = "404554e5-9175-474e-b211-1a31ebde49e2"; -const IMPROVEMENT_LABEL = "ad32e36f-0027-41db-9e49-ad99864dc7cc"; // "Other" carries no Type; the owner picks one while triaging. const NEEDS_DECISION_LABEL = "0968e82c-0db3-48ce-83c5-fe4afd04a5b3"; const LABEL_BY_CATEGORY = { bug: BUG_LABEL, idea: FEATURE_LABEL, other: NEEDS_DECISION_LABEL }; -const TYPE_LABELS = [BUG_LABEL, FEATURE_LABEL, IMPROVEMENT_LABEL]; const CATEGORY_NAME = { bug: "Bug", idea: "Idea", other: "Other" }; -/** - * Backlog is the moderation gate: an unreviewed submission is never public. - * Moving it to Todo publishes it. The board asks Linear for published state - * types only, so Backlog, Canceled and Duplicate never take its slots, and it - * maps by type so renaming a status neither hides nor leaks cards. - */ -const STATUS_BY_STATE_TYPE = { unstarted: "pending", started: "review", completed: "done" }; -export const DONE_LIMIT = 30; -const OPEN_LIMIT = 100; - const CREATE_ISSUE = `mutation CreateFeedback($input: IssueCreateInput!) { issueCreate(input: $input) { success } }`; - const FIND_ISSUE = `query FindFeedback($id: String!) { issue(id: $id) { id } }`; - -// Every connection names its own `first`: Linear prices an unbounded one at 50 -// nodes, and its complexity budget is shared with the key owner's other tools. -const BOARD_FILTER = "team: { id: { eq: $team } }, labels: { some: { id: { eq: $label } } }"; -const LIST_ISSUES = `query FeedbackBoard($team: ID!, $label: ID!, $types: [ID!]) { - open: issues( - first: ${OPEN_LIMIT} - orderBy: updatedAt - filter: { ${BOARD_FILTER}, state: { type: { in: ["unstarted", "started"] } } } - ) { nodes { ...BoardIssue } } - done: issues( - first: ${DONE_LIMIT} - orderBy: updatedAt - filter: { ${BOARD_FILTER}, state: { type: { eq: "completed" } } } - ) { nodes { ...BoardIssue } } -} -fragment BoardIssue on Issue { - identifier - title - updatedAt - state { type } - labels(first: 3, filter: { id: { in: $types } }) { nodes { id } } +const FEEDBACK_STATE = `query FeedbackState($id: ID!, $label: ID!) { + issues(first: 1, includeArchived: true, filter: { id: { eq: $id } }) { + nodes { + id identifier updatedAt archivedAt autoArchivedAt team { id } state { id type } + labels(first: 1, filter: { id: { eq: $label } }) { nodes { id } } + } + } }`; const PROBE = `query FeedbackProbe( @@ -145,56 +117,36 @@ export async function createFeedbackIssue(env, feedback) { } } -function category(labelIds) { - if (labelIds.includes(BUG_LABEL)) return "bug"; - if (labelIds.includes(FEATURE_LABEL) || labelIds.includes(IMPROVEMENT_LABEL)) return "idea"; - return "other"; -} - -/** Only these five fields leave the Worker; the description never does. */ -export function toBoardItem(issue) { - const status = STATUS_BY_STATE_TYPE[issue?.state?.type]; - if ( - !status || - typeof issue.identifier !== "string" || - typeof issue.title !== "string" || - typeof issue.updatedAt !== "string" - ) { - return undefined; +/** Only D1-owned issue ids are passed here; the public board never queries Linear. */ +export async function readFeedbackState(env, id) { + const data = await linear(env, FEEDBACK_STATE, { id, label: env.FEEDBACK_LABEL_ID }); + const issue = data.issues?.nodes?.[0]; + // Missing/inaccessible is not proof of owner deletion. Signed remove events + // handle deletion; an API outage must never silently remove public feedback. + if (!issue || issue.id !== id || !issue.state || !Array.isArray(issue.labels?.nodes)) { + throw new Error("Feedback issue is unavailable"); } - const labelIds = (issue.labels?.nodes ?? []).map((label) => label?.id); + const version = Math.max(Date.parse(issue.updatedAt), Date.parse(issue.archivedAt) || 0); + if (!Number.isFinite(version) || typeof issue.identifier !== "string") { + throw new Error("Invalid feedback state"); + } + // Automatic Linear archiving must not age old approved feedback off the board. + const manuallyArchived = issue.archivedAt && !issue.autoArchivedAt; + const visible = + issue.team?.id === env.FEEDBACK_TEAM_ID && + !manuallyArchived && + issue.labels.nodes.some((label) => label.id === env.FEEDBACK_LABEL_ID); + const status = visible + ? ({ unstarted: "pending", started: "review", completed: "done" }[issue.state.type] ?? "hidden") + : "hidden"; return { - id: issue.identifier, - title: issue.title, - category: category(labelIds), status, - updatedAt: issue.updatedAt, + version, + identifier: issue.identifier, + inProgress: visible && issue.state.id === env.FEEDBACK_PROGRESS_STATE_ID, }; } -export function toBoard(nodes) { - const items = nodes - .map(toBoardItem) - .filter(Boolean) - .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); - let done = 0; - // Done only grows; the column keeps the most recent items instead of the whole history. - return items.filter((item) => item.status !== "done" || ++done <= DONE_LIMIT); -} - -export async function listFeedbackBoard(env) { - const data = await linear(env, LIST_ISSUES, { - team: env.FEEDBACK_TEAM_ID, - label: env.FEEDBACK_LABEL_ID, - types: TYPE_LABELS, - }); - const open = data.open?.nodes; - const done = data.done?.nodes; - if (!Array.isArray(open) || !Array.isArray(done)) - throw new Error("Linear returned no issue list"); - return toBoard([...open, ...done]); -} - /** * Reject unless the key works and the team, labels and Backlog state it writes * to still exist unarchived. Names only — nothing a visitor typed. diff --git a/backend/src/runtime.test.mjs b/backend/src/runtime.test.mjs index 9d146832..5bb9b820 100644 --- a/backend/src/runtime.test.mjs +++ b/backend/src/runtime.test.mjs @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import { test } from "node:test"; import { build } from "esbuild"; import { Miniflare } from "miniflare"; +import { createFeedbackRepository } from "./feedback-repository.mjs"; import { createUsageRepository, RAW_RETENTION_MS } from "./usage-repository.mjs"; test( @@ -60,6 +61,38 @@ test( assert.equal(rows.results.length, 1); assert.deepEqual(JSON.parse(rows.results[0].agents), { claude: 3 }); const repository = createUsageRepository(db); + const feedbackRepository = createFeedbackRepository(db); + const feedbackId = await feedbackRepository.create( + { + id: crypto.randomUUID(), + title: "Keep my feedback", + body: "Private until approved", + category: "idea", + }, + { sub: "google-id", email: "sender@gmail.com" }, + ); + const moderation = { + status: "pending", + version: 1000, + identifier: "DECK-999", + inProgress: false, + }; + await db + .prepare( + "CREATE TRIGGER refuse_feedback_mail BEFORE INSERT ON feedback_mail BEGIN SELECT raise(ABORT, 'refused'); END", + ) + .run(); + await assert.rejects(feedbackRepository.applyModeration(feedbackId, moderation)); + assert.equal((await feedbackRepository.findById(feedbackId)).status, "private"); + await db.prepare("DROP TRIGGER refuse_feedback_mail").run(); + await feedbackRepository.applyModeration(feedbackId, moderation); + const feedbackResponse = await mf.dispatchFetch("https://api.deck.spacevibe.dev/v1/feedback"); + assert.equal(feedbackResponse.status, 200); + assert.equal((await feedbackResponse.json()).items[0].description, "Private until approved"); + const lease = await feedbackRepository.acquireLease(Date.now()); + assert.ok(lease); + assert.equal(await feedbackRepository.acquireLease(Date.now()), null); + await feedbackRepository.releaseLease(lease); // `expire` puts the aggregate insert and the raw delete in one `db.batch`, // and the whole retention design rests on that being a transaction: if the @@ -80,6 +113,7 @@ test( await repository.expire(Date.now() + RAW_RETENTION_MS); await repository.expire(Date.now() + RAW_RETENTION_MS); + assert.equal((await feedbackRepository.findById(feedbackId)).status, "pending"); assert.equal((await db.prepare("SELECT count(*) AS n FROM usage_days").first()).n, 0); assert.equal( (await db.prepare("SELECT participating_installs AS n FROM usage_aggregates").first()).n, diff --git a/backend/src/worker.mjs b/backend/src/worker.mjs index 7654ae10..c3c4fdcb 100644 --- a/backend/src/worker.mjs +++ b/backend/src/worker.mjs @@ -1,4 +1,6 @@ -import { FEEDBACK_PATH, handleFeedback } from "./feedback-routes.mjs"; +import { FEEDBACK_SYNC_CRON, syncFeedback } from "./feedback-sync.mjs"; +import { FEEDBACK_WEBHOOK_PATH, handleFeedbackWebhook } from "./feedback-webhook.mjs"; +import { FEEDBACK_PATH, FEEDBACK_CONFIG_PATH, handleFeedback } from "./feedback-routes.mjs"; import { FEEDBACK_PROBE_CRON, probeFeedbackConfig } from "./linear-feedback.mjs"; import { PayloadError, readPayload } from "./payload.mjs"; import { createUsageRepository } from "./usage-repository.mjs"; @@ -12,7 +14,9 @@ const HEADERS = { "cache-control": "no-store", "x-content-type-options": "nosnif export default { async fetch(request, env) { const url = new URL(request.url); - if (url.pathname === FEEDBACK_PATH) return handleFeedback(request, env); + if (url.pathname === FEEDBACK_WEBHOOK_PATH) return handleFeedbackWebhook(request, env); + if (url.pathname === FEEDBACK_PATH || url.pathname === FEEDBACK_CONFIG_PATH) + return handleFeedback(request, env); if (url.pathname !== "/v1/ping") return new Response(null, { status: 404, headers: HEADERS }); if (request.method !== "POST") return new Response(null, { status: 405, headers: { ...HEADERS, allow: "POST" } }); @@ -39,6 +43,10 @@ export default { } }, async scheduled(controller, env) { + if (controller.cron === FEEDBACK_SYNC_CRON) { + await syncFeedback(env); + return; + } // The feedback probe has its own cron: its failure means "feedback is // broken", never "retention is overdue". if (controller.cron === FEEDBACK_PROBE_CRON) { diff --git a/backend/wrangler.jsonc b/backend/wrangler.jsonc index bbd61541..129de9b5 100644 --- a/backend/wrangler.jsonc +++ b/backend/wrangler.jsonc @@ -9,26 +9,45 @@ "logpush": false, "observability": { "enabled": false, - "logs": { "enabled": false, "invocation_logs": false }, - "traces": { "enabled": false } + "logs": { + "enabled": false, + "invocation_logs": false + }, + "traces": { + "enabled": false + } }, - "routes": [{ "pattern": "api.deck.spacevibe.dev", "custom_domain": true }], + "routes": [ + { + "pattern": "api.deck.spacevibe.dev", + "custom_domain": true + } + ], "ratelimits": [ { "name": "INGEST_LIMITER", "namespace_id": "34201", - "simple": { "limit": 1000, "period": 60 } + "simple": { + "limit": 1000, + "period": 60 + } }, { "name": "FEEDBACK_LIMITER", "namespace_id": "34202", - "simple": { "limit": 20, "period": 60 } + "simple": { + "limit": 20, + "period": 60 + } } ], "vars": { "FEEDBACK_TEAM_ID": "cb65f88e-7252-4bcf-b4c7-30030b26ecea", "FEEDBACK_LABEL_ID": "d67dc808-53f9-4fde-9483-2cffe14c0169", - "FEEDBACK_BACKLOG_STATE_ID": "cd3a4095-d1d6-48a3-8d10-82231ff5f521" + "FEEDBACK_BACKLOG_STATE_ID": "cd3a4095-d1d6-48a3-8d10-82231ff5f521", + "FEEDBACK_SUBMISSIONS_OPEN": "false", + "FEEDBACK_SYNC_ENABLED": "false", + "FEEDBACK_PROGRESS_STATE_ID": "dc9f6de9-3d8a-45e5-a7c4-ea2bcec5cace" }, "d1_databases": [ { @@ -38,5 +57,7 @@ "migrations_dir": "migrations" } ], - "triggers": { "crons": ["0 3 * * *", "17 * * * *"] } + "triggers": { + "crons": ["0 3 * * *", "17 * * * *", "* * * * *"] + } } diff --git a/marketing/landing-prototype/src/copy.js b/marketing/landing-prototype/src/copy.js index 3ff5f93a..7f3de14a 100644 --- a/marketing/landing-prototype/src/copy.js +++ b/marketing/landing-prototype/src/copy.js @@ -33,10 +33,11 @@ export const messages = { feedbackIntro: "Hit a bug, or wish Deck did something new? Tell us. Accepted requests show up on the board and move along as we work on them.", feedbackTitleHint: "At least 3 characters", - feedbackSentBody: "Every note gets read. Once it has been reviewed, it shows up on the board.", + feedbackSentBody: + "Saved for review. We will email you when it is approved and when work starts.", feedbackSendAnother: "Send another", feedbackEmptyPending: "Nothing waiting right now.", - feedbackEmptyReview: "Nothing in review yet.", + feedbackEmptyReview: "Nothing in progress yet.", feedbackEmptyDone: "Finished requests land here.", feedbackFormTitle: "Send feedback", feedbackCategoryLabel: "Type", @@ -51,13 +52,16 @@ export const messages = { feedbackSending: "Sending…", feedbackSent: "Thank you — it is in!", feedbackErrorInvalid: "Check the title (3–120 characters) and the details (up to 2,000).", + feedbackErrorAuth: "Sign in with Google again. Your draft has been kept.", + feedbackErrorConflict: + "This draft was already received with different details. Copy your edits before starting another report.", feedbackErrorRate: "Too many submissions right now. Try again in a minute.", feedbackErrorServer: "Could not send your feedback. Try again later.", feedbackNotice: - "Titles of accepted feedback are shown publicly. Leave out personal information.", + "After approval, your title, details and feedback type are public. Keep personal information out of your report. Your Google email stays private and receives approval and progress updates.", feedbackBoardTitle: "Board", feedbackColumnPending: "Pending", - feedbackColumnReview: "In review", + feedbackColumnReview: "In progress", feedbackColumnDone: "Done", feedbackLoading: "Loading the board…", feedbackError: "Could not load the board.", @@ -162,10 +166,11 @@ export const messages = { feedbackIntro: "Gặp lỗi, hay muốn Deck làm thêm điều gì? Cứ kể cho chúng tôi. Góp ý được duyệt sẽ lên bảng và đi dần qua các cột theo tiến độ.", feedbackTitleHint: "Tối thiểu 3 ký tự", - feedbackSentBody: "Góp ý nào cũng được đọc. Sau khi duyệt, nó sẽ hiện trên bảng.", + feedbackSentBody: + "Saved for review. We will email you when it is approved and when work starts.", feedbackSendAnother: "Gửi thêm góp ý", feedbackEmptyPending: "Hiện chưa có gì đang chờ.", - feedbackEmptyReview: "Chưa có gì đang xem xét.", + feedbackEmptyReview: "Nothing in progress yet.", feedbackEmptyDone: "Việc đã xong sẽ nằm ở đây.", feedbackFormTitle: "Gửi góp ý", feedbackCategoryLabel: "Loại", @@ -180,14 +185,18 @@ export const messages = { feedbackSubmit: "Gửi góp ý", feedbackSending: "Đang gửi…", feedbackSent: "Cảm ơn bạn — đã nhận!", - feedbackErrorInvalid: "Kiểm tra lại tiêu đề (3–120 ký tự) và phần chi tiết (tối đa 2.000 ký tự).", + feedbackErrorInvalid: + "Kiểm tra lại tiêu đề (3–120 ký tự) và phần chi tiết (tối đa 2.000 ký tự).", + feedbackErrorAuth: "Sign in with Google again. Your draft has been kept.", + feedbackErrorConflict: + "This draft was already received with different details. Copy your edits before starting another report.", feedbackErrorRate: "Đang có quá nhiều lượt gửi. Thử lại sau một phút.", feedbackErrorServer: "Không gửi được góp ý. Thử lại sau nhé.", feedbackNotice: - "Tiêu đề của góp ý đã duyệt sẽ hiển thị công khai. Đừng ghi thông tin cá nhân.", + "After approval, your title, details and feedback type are public. Keep personal information out of your report. Your Google email stays private and receives approval and progress updates.", feedbackBoardTitle: "Bảng tiến độ", feedbackColumnPending: "Đang chờ", - feedbackColumnReview: "Đang xem xét", + feedbackColumnReview: "In progress", feedbackColumnDone: "Đã xong", feedbackLoading: "Đang tải bảng…", feedbackError: "Không tải được bảng.", @@ -195,7 +204,8 @@ export const messages = { "Sắp mở gửi góp ý. Những gì bạn viết ở đây được giữ lại trên máy này, nên lúc mở gửi nó vẫn còn nguyên.", feedbackSubmitClosed: "Sắp mở gửi", feedbackDraftSaved: "Đã lưu nháp trên máy này", - feedbackDraftRefused: "Trình duyệt này không cho lưu nháp. Hãy chép lại nội dung trước khi rời trang.", + feedbackDraftRefused: + "Trình duyệt này không cho lưu nháp. Hãy chép lại nội dung trước khi rời trang.", feedbackBoardSoon: "Bảng sẽ hiện khi mở gửi góp ý.", panelRestoreTitle: "Đóng rồi mở lại. Không mất mạch nào.", panelRestoreBody: diff --git a/marketing/landing-prototype/src/feedback-api.js b/marketing/landing-prototype/src/feedback-api.js index 11e35ca9..4c64621c 100644 --- a/marketing/landing-prototype/src/feedback-api.js +++ b/marketing/landing-prototype/src/feedback-api.js @@ -1,6 +1,6 @@ /** * The landing's side of the feedback contract served by `backend/` - * (DECK-101). The Worker owns validation and the Linear round trip; this + * (DECK-101). The Worker owns authentication and durable storage; this * module only shapes the request and refuses to trust the response. */ @@ -12,6 +12,8 @@ export const FEEDBACK_API_URL = "https://api.deck.spacevibe.dev/v1/feedback"; * the API. Flip to true in the same change that deploys the Worker. */ export const SUBMISSIONS_OPEN = false; +// Keep public reads enabled when closing intake after the initial rollout. +export const FEEDBACK_BOARD_OPEN = false; /** Board columns, left to right. */ export const FEEDBACK_STATUSES = ["pending", "review", "done"]; @@ -42,6 +44,7 @@ function isCard(item) { item !== null && typeof item === "object" && typeof item.id === "string" && + typeof item.description === "string" && typeof item.title === "string" && item.title.length > 0 && FEEDBACK_STATUSES.includes(item.status) && @@ -72,8 +75,10 @@ export function groupFeedbackBoard(payload) { ); } -export async function fetchFeedbackBoard(fetchImpl = fetch) { - const response = await fetchImpl(FEEDBACK_API_URL, { +export async function fetchFeedbackBoard(cursor = null, fetchImpl = fetch) { + const url = new URL(FEEDBACK_API_URL); + if (cursor) url.searchParams.set("cursor", cursor); + const response = await fetchImpl(url.toString(), { headers: { accept: "application/json" }, signal: AbortSignal.timeout(BOARD_TIMEOUT_MS), }); @@ -82,12 +87,16 @@ export async function fetchFeedbackBoard(fetchImpl = fetch) { throw new Error(`Feedback board request failed with ${response.status}.`); } - return groupFeedbackBoard(await response.json()); + const payload = await response.json(); + if (payload.nextCursor !== null && typeof payload.nextCursor !== "string") { + throw new Error("Feedback board response has an invalid cursor."); + } + return { board: groupFeedbackBoard(payload), nextCursor: payload.nextCursor }; } /** - * `id` is the draft's UUID: a resend after a lost answer names the same Linear - * issue instead of creating a second one. Omitted when the browser has none. + * `id` is the draft's UUID: a resend after a lost answer names the same stored + * submission instead of creating a second one. Omitted when the browser has none. * * @param {{ title: string, body: string, category: string, website: string, id: string }} input */ @@ -97,7 +106,10 @@ export async function submitFeedback(input, fetchImpl = fetch) { try { response = await fetchImpl(FEEDBACK_API_URL, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + authorization: `Bearer ${input.credential ?? ""}`, + }, body: JSON.stringify({ title: input.title, body: input.body, @@ -111,9 +123,15 @@ export async function submitFeedback(input, fetchImpl = fetch) { throw new FeedbackSubmitError("server"); } - if (response.ok) { + if (response.status === 201) { + const receipt = await response.json(); + if (typeof receipt.id !== "string" || receipt.status !== "private") { + throw new FeedbackSubmitError("server"); + } return; } + if (response.status === 401) throw new FeedbackSubmitError("auth"); + if (response.status === 409) throw new FeedbackSubmitError("conflict"); if (response.status === 400 || response.status === 413) { throw new FeedbackSubmitError("invalid"); @@ -121,3 +139,18 @@ export async function submitFeedback(input, fetchImpl = fetch) { throw new FeedbackSubmitError(response.status === 429 ? "rate" : "server"); } + +export async function fetchFeedbackConfig(fetchImpl = fetch) { + const response = await fetchImpl(`${FEEDBACK_API_URL}/config`, { + signal: AbortSignal.timeout(BOARD_TIMEOUT_MS), + }); + if (!response.ok) throw new Error("Feedback configuration unavailable"); + const config = await response.json(); + if ( + typeof config.submissionsOpen !== "boolean" || + (config.googleClientId !== null && typeof config.googleClientId !== "string") + ) { + throw new Error("Invalid feedback configuration"); + } + return config; +} diff --git a/marketing/landing-prototype/src/feedback-api.test.js b/marketing/landing-prototype/src/feedback-api.test.js index 4a6b3695..61531974 100644 --- a/marketing/landing-prototype/src/feedback-api.test.js +++ b/marketing/landing-prototype/src/feedback-api.test.js @@ -1,76 +1,117 @@ import { describe, expect, it } from "vitest"; - -import { FeedbackSubmitError, groupFeedbackBoard, submitFeedback } from "./feedback-api.js"; +import { + FeedbackSubmitError, + groupFeedbackBoard, + submitFeedback, + fetchFeedbackBoard, + fetchFeedbackConfig, +} from "./feedback-api.js"; const card = (overrides) => ({ - id: "DECK-1", + id: "report-id", title: "Split panes", + description: "Steps", category: "idea", status: "pending", updatedAt: "2026-09-14T10:00:00.000Z", ...overrides, }); +const json = (value, status = 200) => new Response(JSON.stringify(value), { status }); -describe("groupFeedbackBoard", () => { - it("groups by status, newest first, and drops malformed items", () => { +describe("public board", () => { + it("groups approved cards and drops malformed/private cards", () => { const board = groupFeedbackBoard({ items: [ - card({ id: "DECK-2", updatedAt: "2026-09-12T10:00:00.000Z" }), - card({ id: "DECK-3" }), - card({ id: "DECK-4", status: "done" }), - card({ id: "DECK-5", status: "backlog" }), - card({ id: "DECK-6", title: "" }), - { id: "DECK-7" }, + card({ id: "older", updatedAt: "2026-09-12T00:00:00Z" }), + card({ id: "newer" }), + card({ id: "done", status: "done" }), + card({ status: "private" }), + card({ description: undefined }), ], }); - - expect(board.pending.map((item) => item.id)).toEqual(["DECK-3", "DECK-2"]); + expect(board.pending.map((x) => x.id)).toEqual(["newer", "older"]); expect(board.review).toEqual([]); - expect(board.done.map((item) => item.id)).toEqual(["DECK-4"]); - }); - - it("rejects a response without an items list", () => { + expect(board.done.map((x) => x.id)).toEqual(["done"]); expect(() => groupFeedbackBoard({})).toThrow(); }); + it("requests older pages without auth and preserves the next cursor", async () => { + let requested; + const page = await fetchFeedbackBoard("123:uuid", async (url, init) => { + requested = { url, init }; + return json({ items: [card()], nextCursor: "122:uuid" }); + }); + expect(new URL(requested.url).searchParams.get("cursor")).toBe("123:uuid"); + expect(requested.init.headers.authorization).toBeUndefined(); + expect(page.nextCursor).toBe("122:uuid"); + expect(page.board.pending).toHaveLength(1); + await expect( + fetchFeedbackBoard(null, async () => json({ items: [], nextCursor: {} })), + ).rejects.toThrow(); + }); }); -describe("submitFeedback", () => { - const input = { title: "Split panes", body: "", category: "idea", website: "", id: "" }; - - it("names the draft's id when it has one, with a timeout on the request", async () => { - const sent = []; - const capture = async (_url, init) => { - sent.push({ body: JSON.parse(init.body), signal: init.signal }); - return new Response(null, { status: 204 }); - }; - const id = "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed"; - - await submitFeedback({ ...input, id }, capture); - await submitFeedback(input, capture); - - expect(sent[0].body.id).toBe(id); - expect("id" in sent[1].body).toBe(false); - expect(sent[0].signal).toBeInstanceOf(AbortSignal); +describe("durable submission", () => { + const input = { + title: "Split panes", + body: "", + category: "idea", + website: "", + id: "draft-uuid", + credential: "google-credential", + }; + it("sends auth only in the header and accepts a persisted receipt", async () => { + let sent; + await submitFeedback(input, async (_url, init) => { + sent = init; + return json({ id: "stored-id", status: "private" }, 201); + }); + expect(sent.headers.authorization).toBe("Bearer google-credential"); + expect(JSON.parse(sent.body)).toEqual({ + title: input.title, + body: "", + category: "idea", + website: "", + id: "draft-uuid", + }); + expect(sent.signal).toBeInstanceOf(AbortSignal); }); - - const respond = (status) => async () => new Response(null, { status }); - - it("maps the Worker's status codes to a reason", async () => { - await expect(submitFeedback(input, respond(204))).resolves.toBeUndefined(); - + it("never treats an empty or malformed success response as persisted feedback", async () => { + for (const response of [ + new Response(null, { status: 204 }), + json({ status: "private" }, 201), + json({ id: "x", status: "public" }, 201), + ]) { + await expect(submitFeedback(input, async () => response)).rejects.toBeInstanceOf( + FeedbackSubmitError, + ); + } + }); + it("distinguishes sign-in expiry and idempotency conflicts from retryable errors", async () => { for (const [status, reason] of [ [400, "invalid"], [413, "invalid"], + [401, "auth"], + [409, "conflict"], [429, "rate"], [503, "server"], ]) { - await expect(submitFeedback(input, respond(status))).rejects.toMatchObject({ reason }); + await expect( + submitFeedback(input, async () => new Response(null, { status })), + ).rejects.toMatchObject({ reason }); } - await expect( submitFeedback(input, async () => { - throw new TypeError("offline"); + throw new Error("offline"); }), - ).rejects.toBeInstanceOf(FeedbackSubmitError); + ).rejects.toMatchObject({ reason: "server" }); + }); +}); + +describe("feedback configuration", () => { + it("validates provider config without treating a server error as open intake", async () => { + const config = { googleClientId: "test-client", submissionsOpen: false }; + expect(await fetchFeedbackConfig(async () => json(config))).toEqual(config); + await expect(fetchFeedbackConfig(async () => json({}))).rejects.toThrow(); + await expect(fetchFeedbackConfig(async () => json({}, 503))).rejects.toThrow(); }); }); diff --git a/marketing/landing-prototype/src/feedback-auth.js b/marketing/landing-prototype/src/feedback-auth.js new file mode 100644 index 00000000..dc674135 --- /dev/null +++ b/marketing/landing-prototype/src/feedback-auth.js @@ -0,0 +1,84 @@ +const GOOGLE_SCRIPT = "https://accounts.google.com/gsi/client"; +const LOAD_TIMEOUT_MS = 12_000; +let loading; + +function loadGoogle() { + if (globalThis.google?.accounts?.id) return Promise.resolve(); + if (loading) return loading; + loading = new Promise((resolve, reject) => { + const script = document.createElement("script"); + const fail = () => { + clearTimeout(timer); + script.remove(); + loading = undefined; + reject(new Error("Google sign-in could not load")); + }; + const timer = setTimeout(fail, LOAD_TIMEOUT_MS); + script.src = GOOGLE_SCRIPT; + script.async = true; + script.onload = () => { + clearTimeout(timer); + if (globalThis.google?.accounts?.id) resolve(); + else fail(); + }; + script.onerror = fail; + document.head.append(script); + }); + return loading; +} + +/** Tokens live only in memory and are verified by the Worker on every submit. */ +export function createFeedbackAuth(root, clientId, changed) { + let credential = null; + const container = root.querySelector("[data-google-signin]"); + const status = root.querySelector("[data-auth-status]"); + const signout = root.querySelector("[data-auth-signout]"); + const retry = root.querySelector("[data-auth-retry]"); + const section = root.querySelector("[data-feedback-auth]"); + section.hidden = false; + const update = (message) => { + status.textContent = message; + container.hidden = Boolean(credential); + signout.hidden = !credential; + changed(Boolean(credential)); + }; + const reset = () => { + credential = null; + globalThis.google?.accounts?.id.disableAutoSelect(); + update("Sign in with Google to send feedback and receive email updates."); + }; + async function start() { + retry.hidden = true; + update("Loading Google sign-in…"); + try { + await loadGoogle(); + google.accounts.id.initialize({ + client_id: clientId, + auto_select: false, + callback: (response) => { + if (typeof response.credential !== "string" || !response.credential) { + reset(); + return; + } + credential = response.credential; + update("Signed in with Google. Your email stays private."); + }, + }); + google.accounts.id.renderButton(container, { + type: "standard", + theme: "outline", + size: "large", + text: "signin_with", + shape: "pill", + }); + reset(); + } catch { + update("Google sign-in is unavailable. Your draft is still here."); + retry.hidden = false; + } + } + signout.addEventListener("click", reset); + retry.addEventListener("click", () => void start()); + void start(); + return { token: () => credential, reset }; +} diff --git a/marketing/landing-prototype/src/feedback-board-view.js b/marketing/landing-prototype/src/feedback-board-view.js index 6989a8e2..801cd8ce 100644 --- a/marketing/landing-prototype/src/feedback-board-view.js +++ b/marketing/landing-prototype/src/feedback-board-view.js @@ -53,6 +53,8 @@ export function renderBoardSection(copy) { + + `; } @@ -89,7 +91,7 @@ function createCard(item, copy, locale, index) { const id = document.createElement("span"); id.className = "feedback-card__id"; - id.textContent = item.id; + id.textContent = item.identifier || item.id; top.append(tag, id); // User-submitted text: always textContent, never markup. @@ -103,7 +105,10 @@ function createCard(item, copy, locale, index) { time.dataset.feedbackDate = item.updatedAt; time.textContent = formatRelativeDay(item.updatedAt, locale); - card.append(top, title, time); + const description = document.createElement("p"); + description.className = "feedback-card__description"; + description.textContent = item.description; + card.append(top, title, description, time); return card; } diff --git a/marketing/landing-prototype/src/feedback-controller.test.js b/marketing/landing-prototype/src/feedback-controller.test.js new file mode 100644 index 00000000..00713b96 --- /dev/null +++ b/marketing/landing-prototype/src/feedback-controller.test.js @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; + +const api = vi.hoisted(() => ({ submit: vi.fn(), board: vi.fn(), config: vi.fn() })); +vi.mock("./feedback-api.js", async (original) => ({ + ...(await original()), + SUBMISSIONS_OPEN: true, + submitFeedback: api.submit, + fetchFeedbackBoard: api.board, + fetchFeedbackConfig: api.config, +})); +vi.mock("./feedback-auth.js", () => ({ + createFeedbackAuth: (_root, _client, changed) => { + queueMicrotask(() => changed(true)); + return { token: () => "credential", reset: () => changed(false) }; + }, +})); + +afterEach(() => { + document.body.replaceChildren(); + localStorage.clear(); + vi.resetModules(); + vi.clearAllMocks(); +}); +async function mount() { + document.body.innerHTML = '
'; + api.config.mockResolvedValue({ googleClientId: "client", submissionsOpen: true }); + api.board.mockResolvedValue({ board: { pending: [], review: [], done: [] }, nextCursor: null }); + await import("./feedback.js"); + await vi.waitFor(() => + expect(document.querySelector("#feedback-root").dataset.authReady).toBe("true"), + ); + return document.querySelector(".feedback-form"); +} + +it("keeps category and id after a lost acknowledgment and reload, then clears only after success", async () => { + let form = await mount(); + form.querySelector('[name="title"]').value = "Keep this idea"; + form.querySelector('[name="body"]').value = "Details"; + form.querySelector('[name="category"][value="idea"]').checked = true; + api.submit.mockRejectedValueOnce(new Error("response lost")); + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + await vi.waitFor(() => expect(api.submit).toHaveBeenCalledTimes(1)); + const first = api.submit.mock.calls[0][0]; + const stored = JSON.parse(localStorage.getItem("deck.landing.feedbackDraft.v1")); + expect(stored.category).toBe("idea"); + expect(stored.id).toBe(first.id); + vi.resetModules(); + form = await mount(); + expect(form.querySelector('[name="category"]:checked').value).toBe("idea"); + api.submit.mockResolvedValueOnce(undefined); + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + await vi.waitFor(() => expect(api.submit).toHaveBeenCalledTimes(2)); + expect(api.submit.mock.calls[1][0]).toEqual(first); + await vi.waitFor(() => expect(localStorage.getItem("deck.landing.feedbackDraft.v1")).toBeNull()); +}); diff --git a/marketing/landing-prototype/src/feedback-demo.js b/marketing/landing-prototype/src/feedback-demo.js index 94aef563..e57ba932 100644 --- a/marketing/landing-prototype/src/feedback-demo.js +++ b/marketing/landing-prototype/src/feedback-demo.js @@ -13,13 +13,55 @@ const ago = (days) => new Date(Date.now() - days * DAY_MS).toISOString(); const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const ITEMS = [ - { id: "DECK-131", title: "Let me pin an agent card to the top of the rail", category: "idea", status: "pending", updatedAt: ago(0) }, - { id: "DECK-128", title: "Windows installer fails on a secondary drive", category: "bug", status: "pending", updatedAt: ago(1) }, - { id: "DECK-126", title: "Export token usage as CSV", category: "idea", status: "pending", updatedAt: ago(3) }, - { id: "DECK-122", title: "Codex pane loses scrollback after resume", category: "bug", status: "review", updatedAt: ago(0) }, - { id: "DECK-119", title: "Keyboard shortcut to jump to the next waiting agent", category: "idea", status: "review", updatedAt: ago(4) }, - { id: "DECK-110", title: "Support Gemini CLI in the agent catalog", category: "other", status: "done", updatedAt: ago(6) }, - { id: "DECK-104", title: "Browser tab should remember zoom level", category: "idea", status: "done", updatedAt: ago(12) }, + { + id: "DECK-131", + title: "Let me pin an agent card to the top of the rail", + category: "idea", + status: "pending", + updatedAt: ago(0), + }, + { + id: "DECK-128", + title: "Windows installer fails on a secondary drive", + category: "bug", + status: "pending", + updatedAt: ago(1), + }, + { + id: "DECK-126", + title: "Export token usage as CSV", + category: "idea", + status: "pending", + updatedAt: ago(3), + }, + { + id: "DECK-122", + title: "Codex pane loses scrollback after resume", + category: "bug", + status: "review", + updatedAt: ago(0), + }, + { + id: "DECK-119", + title: "Keyboard shortcut to jump to the next waiting agent", + category: "idea", + status: "review", + updatedAt: ago(4), + }, + { + id: "DECK-110", + title: "Support Gemini CLI in the agent catalog", + category: "other", + status: "done", + updatedAt: ago(6), + }, + { + id: "DECK-104", + title: "Browser tab should remember zoom level", + category: "idea", + status: "done", + updatedAt: ago(12), + }, ]; export function createDemoFeedbackApi(mode) { @@ -31,7 +73,18 @@ export function createDemoFeedbackApi(mode) { throw new Error("Demo board failure."); } - return groupFeedbackBoard({ items: mode === "empty" ? [] : ITEMS }); + return { + board: groupFeedbackBoard({ + items: + mode === "empty" + ? [] + : ITEMS.map((item) => ({ + ...item, + description: "An example report shared by a Deck user.", + })), + }), + nextCursor: null, + }; }, async submit() { await wait(LATENCY_MS); diff --git a/marketing/landing-prototype/src/feedback-draft.js b/marketing/landing-prototype/src/feedback-draft.js index ecfe2823..bcb8385b 100644 --- a/marketing/landing-prototype/src/feedback-draft.js +++ b/marketing/landing-prototype/src/feedback-draft.js @@ -18,7 +18,7 @@ const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f /** * A fresh id for a new draft, or "" where the browser has no crypto.randomUUID - * (insecure context) — the Worker then creates the issue without one. + * (insecure context) — sending then stays blocked instead of losing idempotency. */ export function newDraftId() { return globalThis.crypto?.randomUUID?.() ?? ""; diff --git a/marketing/landing-prototype/src/feedback-form-view.js b/marketing/landing-prototype/src/feedback-form-view.js index 0cdb927d..cfe6f240 100644 --- a/marketing/landing-prototype/src/feedback-form-view.js +++ b/marketing/landing-prototype/src/feedback-form-view.js @@ -28,6 +28,12 @@ function renderForm(copy) {