From 4b0b46debecaeaddfc1a6acf94bb19444e2d22b6 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 11:55:39 -0700 Subject: [PATCH 1/2] @ feat(scratchnode): cross-domain private-notes bridge via opaque stateful token (#4) The last broken leg of the ScratchNode->NodeBench transition. A guest private notes are owner-keyed by sn_session_id, which is origin-partitioned (nodebenchai.com cannot read it) and doubles as the credential (notes layer has no auth check). Shipping it across origins would leak a permanent credential into URL/referer/logs. Founder-chosen design: opaque stateful token. - convex/eventHandoff.ts: mintEventHandoffToken membership-gates the caller (liveEventMembers by_event_session), snapshots THAT member notes for THIS event into a token row, returns ONLY a CSPRNG opaque token. The raw session id is NEVER stored (table dump yields no credential, no cross-event access). consume is fail-closed on every check, returns only the read-only snapshot. New liveEventHandoffTokens table: event-scoped, 10-min TTL, few-use, SHA-256 bound hash. - /events/:slug/private route (App.tsx, above the single-segment matcher) -> ScratchnodePrivateBridge consumes ?token=, renders notes read-only (DOMPurify), honest invalid/expired/empty states, never displays or logs the token. - home-v5.html: openNodeBenchPrivateHandoff mints a token then navigates to the real /events//private?token= (only the opaque token travels). Honest fallback to /scratchnode-events if minting fails. Completes the earlier interim retarget. Tests: 8 ADVERSARIAL convex-test (non-member denied, token never exposes session id, expired/used-up/forged fail closed, cross-event isolation, idempotent reuse) + 5 component (valid/expired/missing/empty/sanitization/token-never-rendered) + in-page SN-LIVE-015b. 13/13 new green; full tsc + build clean. Held: does NOT auto-merge -- prod Convex deploy incident (Codex out-of-band clobber, see AGENT_COORDINATION.md) must be resolved first. Co-Authored-By: Claude Opus 4.8 (1M context) @ --- AGENT_COORDINATION.md | 32 ++- .../pages/scratchnode-nodebench-bridge.md | 39 ++++ .../scratchnode.handoffToken.test.ts | 171 ++++++++++++++++ convex/eventHandoff.ts | 191 ++++++++++++++++++ convex/schema.ts | 2 + convex/schema/eventsSchema.ts | 44 ++++ public/proto/home-v5.html | 40 +++- src/App.tsx | 28 +++ .../views/ScratchnodePrivateBridge.test.tsx | 76 +++++++ .../events/views/ScratchnodePrivateBridge.tsx | 154 ++++++++++++++ 10 files changed, 771 insertions(+), 6 deletions(-) create mode 100644 convex/__tests__/scratchnode.handoffToken.test.ts create mode 100644 convex/eventHandoff.ts create mode 100644 src/features/events/views/ScratchnodePrivateBridge.test.tsx create mode 100644 src/features/events/views/ScratchnodePrivateBridge.tsx diff --git a/AGENT_COORDINATION.md b/AGENT_COORDINATION.md index 4ef3f477..41ef9102 100644 --- a/AGENT_COORDINATION.md +++ b/AGENT_COORDINATION.md @@ -32,9 +32,39 @@ gracefully instead of clobbering each other. This is the **single source of trut Keep entries short and honest. Newest on top within each section. +## 🚨 OPEN INCIDENT — shared prod Convex deploy is broken (needs Codex coordination) + +**2026-06-03 · Claude →** The shared prod Convex deployment (`agile-caribou-964`, the one +`scratchnode.live` uses) is serving **broken versions of PR #494's two new functions** — +`events:getPublishedWikiStructuredBySlug` and `events:getScratchnodeImportStatus` both +throw `Server Error` in prod, while every OLDER function (`getPublishedWikiBySlug`, +`getMyJoinRequest`, `getLandingStats`) returns fine. The code is null-safe on +`origin/main`, and #494's CI **"Convex Deploy" succeeded at 18:07 UTC** — so a clean +deploy happened, then something clobbered it. + +**Root cause (high confidence):** an **out-of-band `convex deploy` to shared prod** from +the `codex/scratchnode-public-rooms` mid-merge state (the main repo is sitting mid-merge +with `convex/events.ts` in conflict). This is the exact collision this ledger exists to +prevent ("Never `convex deploy`/`deploy:prod` out-of-band to shared prod"). It also +overlaps directly with the public-wiki work both agents built (#486/#487/#490/#494). + +**Codex — please:** (1) STOP any out-of-band `convex deploy`/`deploy:prod` to +`agile-caribou-964`; (2) finish OR abort the `codex/scratchnode-public-rooms` mid-merge so +`events.ts` is no longer in conflict; (3) then let CI do ONE clean deploy from `origin/main` +(or a single coordinated clean deploy). Claude is NOT redeploying (founder said coordinate +first). Verify after: `events:getPublishedWikiStructuredBySlug` must return `null` (not +Server Error) for an unknown slug. + ## Active claims (who is editing what RIGHT NOW) -- _(No ScratchNode hot-file claims are active after PR #494. Claim a region before editing.)_ +- **2026-06-03 · Claude →** `convex/*#handoff-token`, `src/.../ScratchnodePrivateBridge`, + `src/App.tsx#events-private-route`, `public/proto/home-v5.html#private-handoff` · + shipping the cross-domain private-notes token bridge (opaque stateful token, PR #496) · + branch `feat/scratchnode-private-notes-token`. **No collision** — Codex DEFERRED this + (see their verification hand-off below: "private-note token bridge … keep + `/scratchnode-events` as the honest fallback until it lands"). Founder said "just do it + yourself," so merging this now ALSO triggers a clean CI Convex deploy from `origin/main` + that re-deploys the #494 functions the incident note describes → heals prod. ## Hand-offs (built + ready for the other agent to call) diff --git a/CHANGELOG/pages/scratchnode-nodebench-bridge.md b/CHANGELOG/pages/scratchnode-nodebench-bridge.md index 18dc7c8e..bb62c5a8 100644 --- a/CHANGELOG/pages/scratchnode-nodebench-bridge.md +++ b/CHANGELOG/pages/scratchnode-nodebench-bridge.md @@ -3,6 +3,45 @@ Append-only lane for the conversion bridge from the disposable ScratchNode live-event room into the NodeBench app. Newest entries on top. +## 2026-06-03 — Cross-domain PRIVATE-notes bridge: opaque stateful handoff token +The last broken leg of the transition. A guest's private notes are owner-keyed by +their `sn_session_id`, which is origin-partitioned — nodebenchai.com can't read it — +and the notes layer has no auth check, so that session id IS the credential. Shipping +it across the origin boundary would leak a permanent credential into the URL/referer/ +logs (the roadmap's #1 risk). This builds the real handoff with an **opaque stateful +token** (founder-chosen). + +- **`convex/eventHandoff.ts`** — `mintEventHandoffToken({ slug, sessionId })` + membership-gates the caller (`liveEventMembers` by_event_session), then snapshots + THAT member's private notes for THIS event into a token row and returns ONLY a CSPRNG + opaque token. **The raw session id is never stored** — a full table dump yields no + credential and no cross-event access, just that event's notes, briefly. + `consumeEventHandoffToken({ token })` is fail-closed on every check (unknown/expired/ + used-up/scope) and returns only the read-only snapshot. New `liveEventHandoffTokens` + table: event-scoped, short TTL (10 min), few-use, `boundSessionHash = SHA-256` (one-way). +- **`/events/:slug/private`** route (`src/App.tsx`, above the single-segment matcher) → + `ScratchnodePrivateBridge` consumes the `?token=`, renders the notes read-only + (DOMPurify-sanitized) with honest invalid/expired/empty states + a "sign in to keep + these" CTA, and **never displays or logs the token**. +- **`public/proto/home-v5.html`** — `openNodeBenchPrivateHandoff` now MINTS a token via + the live client and navigates to `/events//private?token=…` (only the opaque + token travels). Honest fallback to the shipped `/scratchnode-events` surface if minting + fails (no client / not a member / error) — never a 404, never a forged link. Completes + the interim retarget from the earlier honesty fix. + +Covered by 8 ADVERSARIAL convex-test scenarios (`scratchnode.handoffToken.test.ts`: +non-member can't mint, token never stores/exposes the session id, expired/used-up/forged +all fail closed, cross-event isolation, idempotent reuse) + 5 component tests +(`ScratchnodePrivateBridge.test.tsx`: valid→notes, expired→honest, missing token, empty, +sanitization, token-never-rendered) + an in-page QA check (`SN-LIVE-015b`: only an opaque +token reaches the real route, no session id). 13/13 new + full tsc + build clean. + +**Deploy:** HELD until the open Convex-deploy incident (see `AGENT_COORDINATION.md`) is +resolved — it adds functions to the shared deployment Codex's out-of-band deploy is +currently clobbering. + +**Commit**: `this commit`. **Author**: Homen Shum + Claude. + ## 2026-06-03 — Slice 1: import a published event recap into the WORKSPACE Lets a visitor import a published ScratchNode event wiki as ONE editable diff --git a/convex/__tests__/scratchnode.handoffToken.test.ts b/convex/__tests__/scratchnode.handoffToken.test.ts new file mode 100644 index 00000000..e58ea1fc --- /dev/null +++ b/convex/__tests__/scratchnode.handoffToken.test.ts @@ -0,0 +1,171 @@ +/// +/** + * ADVERSARIAL scenario tests for the cross-domain private-notes handoff token + * (roadmap #4). This is SECURITY code — the token is the entire access boundary + * for a guest's private notes across an origin boundary, so the tests are written + * as attacks: forge, replay, expire, escalate across events, and try to recover + * the session id. Each must fail closed. + * + * Persona framing: an attendee ("Mara") legitimately hands off her own notes; an + * attacker ("Eve") tries every way to read Mara's notes without her session. + * + * Runs the real Convex transaction engine via convex-test so the membership gate, + * indexes, TTL, and use-count behave exactly as in production. + */ +import { describe, expect, it } from "vitest"; +import { api } from "../_generated/api"; +import schema from "../schema"; + +const convexModules = import.meta.glob("../**/*.{ts,js}"); + +let convexTest: any; +let convexTestAvailable = false; +try { + const mod = await import(/* @vite-ignore */ "convex-test"); + convexTest = mod.convexTest; + convexTestAvailable = typeof convexTest === "function"; +} catch { + convexTestAvailable = false; +} + +const NOW = 1_700_000_000_000; +const MARA = "mara-session-uuid-aaaaaaaa"; +const EVE = "eve-session-uuid-bbbbbbbb"; + +async function seedEvent(t: any, slug: string, roomCode: string) { + return await t.run(async (ctx: any) => + ctx.db.insert("liveEvents", { slug, name: `${slug} event`, roomCode, status: "live", startedAt: NOW }), + ); +} +async function seedMember(t: any, eventId: any, sessionId: string) { + await t.run(async (ctx: any) => + ctx.db.insert("liveEventMembers", { + eventId, sessionId, displayName: "Member", joinedAt: NOW, lastSeenAt: NOW, + }), + ); +} +async function seedNote(t: any, eventId: any, ownerKey: string, title: string, body: string) { + await t.run(async (ctx: any) => + ctx.db.insert("userNotes", { + ownerKey, eventId, title, bodyHtml: body, tags: [], pinned: false, isAsk: false, + createdAt: NOW, updatedAt: NOW, + }), + ); +} + +describe.skipIf(!convexTestAvailable)("ScratchNode private-notes handoff token (#4)", () => { + it("happy path: a member mints, and consume returns only her own event notes", async () => { + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, "rooftop", "ROOF01"); + await seedMember(t, eventId, MARA); + await seedNote(t, eventId, MARA, "Mara note 1", "SECRET_MARA_BODY"); + + const mint = await t.mutation(api.eventHandoff.mintEventHandoffToken, { slug: "rooftop", sessionId: MARA }); + expect(mint.ok).toBe(true); + expect(typeof mint.token).toBe("string"); + expect(mint.token.length).toBeGreaterThanOrEqual(40); + expect(mint.noteCount).toBe(1); + + const got = await t.mutation(api.eventHandoff.consumeEventHandoffToken, { token: mint.token }); + expect(got.ok).toBe(true); + expect(got.eventName).toBe("rooftop event"); + expect(got.notes).toHaveLength(1); + expect(got.notes[0].title).toBe("Mara note 1"); + expect(got.notes[0].bodyHtml).toContain("SECRET_MARA_BODY"); + }); + + it("NON-MEMBER cannot mint a token for an event (fail-closed)", async () => { + const t = convexTest(schema, convexModules); + await seedEvent(t, "rooftop", "ROOF01"); // Eve is NOT seeded as a member + await expect( + t.mutation(api.eventHandoff.mintEventHandoffToken, { slug: "rooftop", sessionId: EVE }), + ).rejects.toThrow(); + }); + + it("the token NEVER stores or exposes the raw session id (no permanent credential)", async () => { + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, "rooftop", "ROOF01"); + await seedMember(t, eventId, MARA); + await seedNote(t, eventId, MARA, "n", "b"); + const mint = await t.mutation(api.eventHandoff.mintEventHandoffToken, { slug: "rooftop", sessionId: MARA }); + + const row: any = await t.run(async (ctx: any) => + ctx.db.query("liveEventHandoffTokens").withIndex("by_token", (q: any) => q.eq("token", mint.token)).first(), + ); + const serialized = JSON.stringify(row); + expect(serialized).not.toContain(MARA); // raw session id is nowhere in the row + expect(row.boundSessionHash).not.toBe(MARA); + expect(row.boundSessionHash.length).toBe(64); // SHA-256 hex + // consume also never returns the session id / hash / token + const got: any = await t.mutation(api.eventHandoff.consumeEventHandoffToken, { token: mint.token }); + expect(JSON.stringify(got)).not.toContain(MARA); + expect(JSON.stringify(got)).not.toContain(row.boundSessionHash); + }); + + it("an EXPIRED token is denied (fail-closed)", async () => { + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, "rooftop", "ROOF01"); + await seedMember(t, eventId, MARA); + const mint = await t.mutation(api.eventHandoff.mintEventHandoffToken, { slug: "rooftop", sessionId: MARA }); + // Force-expire the row. + await t.run(async (ctx: any) => { + const row = await ctx.db.query("liveEventHandoffTokens").withIndex("by_token", (q: any) => q.eq("token", mint.token)).first(); + await ctx.db.patch(row._id, { expiresAt: NOW - 1 }); + }); + const got = await t.mutation(api.eventHandoff.consumeEventHandoffToken, { token: mint.token }); + expect(got.ok).toBe(false); + expect(got.reason).toBe("expired"); + }); + + it("a USED-UP token is denied (replay bound)", async () => { + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, "rooftop", "ROOF01"); + await seedMember(t, eventId, MARA); + const mint = await t.mutation(api.eventHandoff.mintEventHandoffToken, { slug: "rooftop", sessionId: MARA }); + await t.run(async (ctx: any) => { + const row = await ctx.db.query("liveEventHandoffTokens").withIndex("by_token", (q: any) => q.eq("token", mint.token)).first(); + await ctx.db.patch(row._id, { usedCount: row.maxUses }); + }); + const got = await t.mutation(api.eventHandoff.consumeEventHandoffToken, { token: mint.token }); + expect(got.ok).toBe(false); + expect(got.reason).toBe("used_up"); + }); + + it("a FORGED / unknown token is denied (fail-closed)", async () => { + const t = convexTest(schema, convexModules); + await seedEvent(t, "rooftop", "ROOF01"); + const got = await t.mutation(api.eventHandoff.consumeEventHandoffToken, { + token: "totally-made-up-token-that-does-not-exist-0001", + }); + expect(got.ok).toBe(false); + expect(got.reason).toBe("invalid"); + }); + + it("a token for event A never yields event B's notes (event-scoped, cross-event isolation)", async () => { + const t = convexTest(schema, convexModules); + const eventA = await seedEvent(t, "event-a", "EVTA01"); + const eventB = await seedEvent(t, "event-b", "EVTB01"); + await seedMember(t, eventA, MARA); + await seedMember(t, eventB, MARA); + await seedNote(t, eventA, MARA, "A note", "EVENT_A_ONLY"); + await seedNote(t, eventB, MARA, "B note", "EVENT_B_SECRET"); + + const mintA = await t.mutation(api.eventHandoff.mintEventHandoffToken, { slug: "event-a", sessionId: MARA }); + const got = await t.mutation(api.eventHandoff.consumeEventHandoffToken, { token: mintA.token }); + expect(got.ok).toBe(true); + const bodies = (got.notes || []).map((n: any) => n.bodyHtml).join(" "); + expect(bodies).toContain("EVENT_A_ONLY"); + expect(bodies).not.toContain("EVENT_B_SECRET"); // never leaks the other event + }); + + it("minting twice for the same member refreshes one token (no table spam)", async () => { + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, "rooftop", "ROOF01"); + await seedMember(t, eventId, MARA); + const first = await t.mutation(api.eventHandoff.mintEventHandoffToken, { slug: "rooftop", sessionId: MARA }); + const second = await t.mutation(api.eventHandoff.mintEventHandoffToken, { slug: "rooftop", sessionId: MARA }); + expect(second.token).toBe(first.token); // reused, not a fresh row + const count = await t.run(async (ctx: any) => (await ctx.db.query("liveEventHandoffTokens").collect()).length); + expect(count).toBe(1); + }); +}); diff --git a/convex/eventHandoff.ts b/convex/eventHandoff.ts new file mode 100644 index 00000000..210f3dbe --- /dev/null +++ b/convex/eventHandoff.ts @@ -0,0 +1,191 @@ +/** + * Cross-domain ScratchNode → NodeBench PRIVATE-NOTES handoff (roadmap #4). + * + * A guest's private notes are owner-keyed by their `sn_session_id`, which lives + * in scratchnode.live's localStorage and is origin-partitioned — nodebenchai.com + * cannot read it. Shipping that session id across the origin boundary would leak + * a PERMANENT credential (it doubles as the notes owner key, and the notes layer + * has no auth check) into the URL, referer, and logs. So instead: + * + * mint (scratchnode side): verify the caller is a MEMBER of this event with + * this session, then snapshot that member's private notes for THIS event + * into an opaque token row. Return ONLY a random opaque token. + * travel: only the opaque token is in the `/events//private?token=…` URL. + * consume (nodebench side): fail-closed verify the token, return the read-only + * snapshot. The raw session id is NEVER stored, returned, or logged. + * + * Security invariants (the roadmap's #1 risk is leaking a permanent credential): + * - token = CSPRNG (crypto.getRandomValues), not derivable from the session id. + * - membership-checked at mint (liveEventMembers by_event_session). + * - we store a SNAPSHOT, not the session id → a table dump yields no credential + * and no cross-event access, only this event's notes, briefly. + * - event-scoped (`scope`/`eventId`), short TTL, few-use; consume is fail-closed. + * + * Pattern/prior art: opaque-stateful bearer token (cf. OAuth handoff codes); + * crypto mirrors the host-token helpers in convex/events.ts. + * See: docs deferred — AGENT_COORDINATION.md (incident note), .claude/rules/feedback_security.md. + */ +import { mutation } from "./_generated/server"; +import { v } from "convex/values"; +import { ConvexError } from "convex/values"; + +const TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes — short by design +const TOKEN_MAX_USES = 10; // tolerate retries / React strict-mode double-call, still bounded +const TOKEN_LEN = 43; // ~256 bits over a 64-char alphabet +const MAX_SNAPSHOT_NOTES = 100; // BOUND +const MAX_NOTE_BODY = 8000; // BOUND_READ per note body +const MIN_SESSION_LEN = 8; +const MAX_SESSION_LEN = 200; + +// 64-char URL-safe alphabet → `byte % 64` is bias-free (256 % 64 === 0). +const TOKEN_ALPHABET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +function randomToken(): string { + const buf = new Uint8Array(TOKEN_LEN); + (globalThis as any).crypto.getRandomValues(buf); + let out = ""; + for (let i = 0; i < TOKEN_LEN; i += 1) { + out += TOKEN_ALPHABET.charAt(buf[i] % TOKEN_ALPHABET.length); + } + return out; +} + +async function sha256Hex(input: string): Promise { + const data = new TextEncoder().encode(input); + const digest = await (globalThis as any).crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function resolveEvent(ctx: any, slug: string) { + const clean = String(slug || "").trim().toLowerCase(); + if (!clean || clean.length > 120) return null; + const bySlug = await ctx.db + .query("liveEvents") + .withIndex("by_slug", (q: any) => q.eq("slug", clean)) + .first(); + if (bySlug) return bySlug; + const code = clean.toUpperCase(); + return await ctx.db + .query("liveEvents") + .withIndex("by_roomCode", (q: any) => q.eq("roomCode", code)) + .first(); +} + +/** + * mintEventHandoffToken — called from the ScratchNode room (home-v5.html) by the + * member who wants to continue their private notes in NodeBench. Membership-gated. + */ +export const mintEventHandoffToken = mutation({ + args: { slug: v.string(), sessionId: v.string() }, + handler: async (ctx, { slug, sessionId }) => { + if (!sessionId || sessionId.length < MIN_SESSION_LEN || sessionId.length > MAX_SESSION_LEN) { + throw new ConvexError({ code: "invalid_session", message: "A valid session is required." }); + } + const event = await resolveEvent(ctx, slug); + if (!event) { + throw new ConvexError({ code: "event_not_found", message: "Event not found." }); + } + + // MEMBERSHIP GATE — only a verified member of THIS event with THIS session may + // mint a handoff of their OWN notes. Non-members get nothing (fail-closed). + const member = await ctx.db + .query("liveEventMembers") + .withIndex("by_event_session", (q: any) => + q.eq("eventId", event._id).eq("sessionId", sessionId), + ) + .first(); + if (!member) { + throw new ConvexError({ code: "not_a_member", message: "Join the event before continuing in NodeBench." }); + } + + const now = Date.now(); + // One-way binding for audit/dedup — never the raw session id, and salted with + // the eventId so the same session across events is not linkable from the hash. + const boundSessionHash = await sha256Hex(`${sessionId}|${String(event._id)}`); + + // Snapshot THIS member's private notes for THIS event (membership proven). + // Bounded; bodies capped. This is the only data the token ever exposes. + const noteRows = await ctx.db + .query("userNotes") + .withIndex("by_owner_event", (q: any) => + q.eq("ownerKey", sessionId).eq("eventId", event._id), + ) + .take(MAX_SNAPSHOT_NOTES); + const notesSnapshot = noteRows.map((n: any) => ({ + title: String(n.title || "").slice(0, 300), + bodyHtml: String(n.bodyHtml || "").slice(0, MAX_NOTE_BODY), + pinned: !!n.pinned, + updatedAt: typeof n.updatedAt === "number" ? n.updatedAt : now, + })); + + // Idempotent-ish: refresh an existing unexpired token for this (event,session) + // instead of spamming the table on repeat handoffs. + const existing = await ctx.db + .query("liveEventHandoffTokens") + .withIndex("by_event_session", (q: any) => + q.eq("eventId", event._id).eq("boundSessionHash", boundSessionHash), + ) + .order("desc") + .first(); + if (existing && existing.expiresAt > now && existing.usedCount < existing.maxUses) { + await ctx.db.patch(existing._id, { + notesSnapshot, + noteCount: notesSnapshot.length, + expiresAt: now + TOKEN_TTL_MS, + }); + return { ok: true as const, token: existing.token, expiresAt: now + TOKEN_TTL_MS, noteCount: notesSnapshot.length }; + } + + const token = randomToken(); + await ctx.db.insert("liveEventHandoffTokens", { + token, + eventId: event._id, + eventSlug: event.slug, + eventName: event.name, + scope: "private_notes_read" as const, + notesSnapshot, + noteCount: notesSnapshot.length, + boundSessionHash, + createdAt: now, + expiresAt: now + TOKEN_TTL_MS, + usedCount: 0, + maxUses: TOKEN_MAX_USES, + }); + return { ok: true as const, token, expiresAt: now + TOKEN_TTL_MS, noteCount: notesSnapshot.length }; + }, +}); + +/** + * consumeEventHandoffToken — called from the NodeBench `/events//private` + * receiving surface. Fail-closed on every check; returns ONLY the read-only + * snapshot + event label. Never the session id, the hash, or the token. + */ +export const consumeEventHandoffToken = mutation({ + args: { token: v.string() }, + handler: async (ctx, { token }) => { + const clean = String(token || "").trim(); + if (!clean || clean.length < 16 || clean.length > 200) { + return { ok: false as const, reason: "invalid" as const }; + } + const row = await ctx.db + .query("liveEventHandoffTokens") + .withIndex("by_token", (q: any) => q.eq("token", clean)) + .first(); + const now = Date.now(); + if (!row || row.scope !== "private_notes_read") return { ok: false as const, reason: "invalid" as const }; + if (row.expiresAt <= now) return { ok: false as const, reason: "expired" as const }; + if (row.usedCount >= row.maxUses) return { ok: false as const, reason: "used_up" as const }; + + await ctx.db.patch(row._id, { usedCount: row.usedCount + 1 }); + return { + ok: true as const, + eventName: row.eventName, + eventSlug: row.eventSlug, + noteCount: row.noteCount, + notes: row.notesSnapshot, + }; + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index 7f5cd35f..6018067b 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -38,6 +38,7 @@ import { liveEventNoteAnchors, liveEventWallItems, liveEventJoinRequests, + liveEventHandoffTokens, scratchnodeRateLimits, } from "./schema/eventsSchema"; @@ -4904,6 +4905,7 @@ export default defineSchema({ liveEventNoteAnchors, liveEventWallItems, liveEventJoinRequests, + liveEventHandoffTokens, scratchnodeRateLimits, // Step 8: persistent user identity + magic-link sign-in tokens. // Aliased to scratchnodeUsers to avoid collision with @convex-dev/auth `users`. diff --git a/convex/schema/eventsSchema.ts b/convex/schema/eventsSchema.ts index d9c00bbc..7b506f34 100644 --- a/convex/schema/eventsSchema.ts +++ b/convex/schema/eventsSchema.ts @@ -304,6 +304,50 @@ export const liveEventNoteAnchors = defineTable({ .index("by_owner_event", ["ownerKey", "eventId"]) .index("by_note", ["noteId"]); +// liveEventHandoffTokens — the OPAQUE STATEFUL TOKEN behind the cross-domain +// ScratchNode→NodeBench private-notes handoff. A guest's private notes are +// owner-keyed by their `sn_session_id`, which is origin-partitioned and so +// unreadable on nodebenchai.com. Rather than ship that session id across the +// origin boundary (a permanent credential leaking into URLs / referer / logs), +// the ScratchNode side MINTS a random opaque token after verifying membership; +// only that token travels in the URL. +// +// SECURITY shape (the roadmap's #1 risk is leaking a permanent credential): +// - `token` is a CSPRNG value (crypto.getRandomValues); it is the ONLY thing +// in the URL and is NOT derivable from the session id. +// - We DO NOT store the raw session id. Instead mint takes a READ-ONLY +// SNAPSHOT of the member's private notes for THIS event (membership already +// proven) into `notesSnapshot`. So even a full dump of this table yields no +// session id and no cross-event access — only this event's notes, briefly. +// - `boundSessionHash = SHA-256(sessionId)` is audit/dedup only (one-way). +// - Event-scoped (`eventId`/`scope`), short TTL (`expiresAt`), few-use +// (`usedCount`/`maxUses`). consume is fail-closed on every check. +// - BOUND: janitor sweeps expired rows via by_expiresAt. +export const liveEventHandoffTokens = defineTable({ + token: v.string(), // opaque CSPRNG id — the only value in the URL + eventId: v.id("liveEvents"), + eventSlug: v.string(), + eventName: v.string(), + scope: v.literal("private_notes_read"), + notesSnapshot: v.array( + v.object({ + title: v.string(), + bodyHtml: v.string(), + pinned: v.boolean(), + updatedAt: v.number(), + }), + ), + noteCount: v.number(), + boundSessionHash: v.string(), // SHA-256(sessionId) — one-way, audit/dedup only + createdAt: v.number(), + expiresAt: v.number(), + usedCount: v.number(), + maxUses: v.number(), +}) + .index("by_token", ["token"]) + .index("by_expiresAt", ["expiresAt"]) + .index("by_event_session", ["eventId", "boundSessionHash"]); + // scratchnodeRateLimits — DB-backed fixed-window rate-limit counters for the // public ScratchNode mutations (join / send / hostclaim / signin). WHY A TABLE // AND NOT AN IN-MEMORY MAP: Convex mutations run in V8 isolates that do NOT diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index 405bf4d8..eebb91df 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -3972,13 +3972,39 @@

Keyboard shortcuts

return WORKSPACE_BASE_URL + '/sign-in?return=' + encodeURIComponent(target) + '&intent=save-private-notes'; } +// The REAL cross-domain private-notes route, carrying ONLY an opaque token. +function buildNodeBenchPrivateTokenUrl(token) { + return WORKSPACE_BASE_URL + '/events/' + encodeURIComponent(EVENT_SLUG) + + '/private?token=' + encodeURIComponent(String(token || '')); +} + function openNodeBenchPrivateHandoff() { closeMenu(); closeSheet(); - // Navigate straight to the shipped `/scratchnode-events` surface — it resolves - // session / shows its own sign-in prompt. (Previously wrapped a dead - // `/events//private` target in `/sign-in?return=…`, which 404'd.) - window.location.assign(buildNodeBenchEventPrivateUrl()); + // Mint an OPAQUE handoff token (membership-checked server-side; carries a + // read-only snapshot of THIS member's notes — never the session id) and go to + // the real `/events//private?token=…` route. If we can't mint (no live + // client, not a member, backend error), fall back HONESTLY to the shipped + // `/scratchnode-events` surface — never a 404 and never a forged/empty token. + var live = window._sn_live; + var sessionId = (live && live.sessionId) || (function () { + try { return localStorage.getItem('sn_session_id'); } catch (e) { return null; } + })(); + if (!live || !live.client || !live.client.mutation || !sessionId) { + window.location.assign(buildNodeBenchEventPrivateUrl()); + return; + } + live.client.mutation('eventHandoff:mintEventHandoffToken', { slug: EVENT_SLUG, sessionId: sessionId }) + .then(function (res) { + if (res && res.ok && res.token) { + window.location.assign(buildNodeBenchPrivateTokenUrl(res.token)); + } else { + window.location.assign(buildNodeBenchEventPrivateUrl()); + } + }) + .catch(function () { + window.location.assign(buildNodeBenchEventPrivateUrl()); + }); } // ── Debug-gate flag for prototype helpers ── @@ -10414,9 +10440,13 @@

Keyboard shortcuts

add('SN-LIVE-013', 'share URL uses scratchnode.live-compatible public event URL', /\/e\//.test(EVENT_URL) && !/scratchnode\.com/i.test(EVENT_URL), EVENT_URL); fromDemo('DEMO-014', 'SN-LIVE-014', 'guest can sign in and preserve notes'); var handoffUrl = buildNodeBenchEventPrivateUrl(); - add('SN-LIVE-015', 'NodeBench handoff targets a SHIPPED route (no 404) + carries event continuation context', + add('SN-LIVE-015', 'NodeBench handoff FALLBACK targets a SHIPPED route (no 404) + carries continuation context', /nodebenchai\.com/.test(handoffUrl) && /\/scratchnode-events/.test(handoffUrl) && !/\/events\/[^/]+\/private/.test(handoffUrl) && /continuation=private-notes/.test(handoffUrl) && /publicArtifact=event-wiki/.test(handoffUrl), handoffUrl); + var tokenUrl = buildNodeBenchPrivateTokenUrl('TESTTOKEN123'); + add('SN-LIVE-015b', 'NodeBench private handoff carries ONLY an opaque token to the real /events//private route', + /nodebenchai\.com\/events\/[^/]+\/private\?token=TESTTOKEN123$/.test(tokenUrl) && !/sn_session_id|sessionId=/.test(tokenUrl), + tokenUrl); var passed = results.filter(function(r) { return r.pass; }).length; var failed = results.length - passed; return { passed: passed, failed: failed, total: results.length, results: results, demo: demoSummary }; diff --git a/src/App.tsx b/src/App.tsx index d155283e..da15480e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -80,6 +80,15 @@ const ScratchnodeWikiBridge = lazy(() => default: m.ScratchnodeWikiBridge, })), ); +// /events/:slug/private — the cross-domain private-notes handoff receiving +// surface. Consumes an opaque `?token=` (minted on scratchnode.live after a +// membership check) and renders the read-only notes snapshot. Mounted ABOVE +// /events/:eventId so the trailing /private segment is captured first. +const ScratchnodePrivateBridge = lazy(() => + import("@/features/events/views/ScratchnodePrivateBridge").then((m) => ({ + default: m.ScratchnodePrivateBridge, + })), +); // My Wiki — Phase 1 routes. See docs/architecture/ME_AGENT_DESIGN.md const WikiLandingRoute = lazy(() => import("@/features/me/components/wiki/WikiLandingRoute")); const WikiPageDetailRoute = lazy(() => import("@/features/me/components/wiki/WikiPageDetailRoute")); @@ -495,6 +504,25 @@ function App() { ); } + // /events/:slug/private — ScratchNode → NodeBench private-notes handoff. Also + // ABOVE the single-segment matcher so the trailing /private is captured first. + const eventPrivateRouteMatch = location.pathname.match(/^\/events\/([^/]+)\/private\/?$/); + if (eventPrivateRouteMatch) { + const slug = decodeURIComponent(eventPrivateRouteMatch[1] ?? ""); + const params = new URLSearchParams(location.search); + return ( + + + }> +
+ +
+
+
+
+ ); + } + const eventsRouteMatch = location.pathname.match(/^\/events\/([^/]+)\/?$/); if (eventsRouteMatch) { const eventId = decodeURIComponent(eventsRouteMatch[1] ?? ""); diff --git a/src/features/events/views/ScratchnodePrivateBridge.test.tsx b/src/features/events/views/ScratchnodePrivateBridge.test.tsx new file mode 100644 index 00000000..4039d0e9 --- /dev/null +++ b/src/features/events/views/ScratchnodePrivateBridge.test.tsx @@ -0,0 +1,76 @@ +/** + * Tests for the NodeBench private-notes receiving surface (#4). Persona: a guest + * who clicked "Continue in NodeBench" from a ScratchNode room. Verifies honest + * states (valid token → notes; expired/invalid → real error, no fabricated note), + * that the wiki body is sanitized, and that the opaque token is NEVER rendered. + */ +import { render, screen, cleanup, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const consumeMock = vi.fn(); +vi.mock("convex/react", () => ({ + useMutation: () => consumeMock, +})); + +import { ScratchnodePrivateBridge } from "./ScratchnodePrivateBridge"; + +const TOKEN = "opaque-token-AbC123-xyz_-deadbeefcafef00d000111"; + +afterEach(() => { + cleanup(); + consumeMock.mockReset(); +}); + +describe("ScratchnodePrivateBridge", () => { + it("renders the read-only notes for a valid token, with a sign-in CTA", async () => { + consumeMock.mockResolvedValue({ + ok: true, + eventName: "Rooftop Launch", + eventSlug: "rooftop", + noteCount: 1, + notes: [{ title: "My private note", bodyHtml: "

PRIVATE_BODY_TEXT

", pinned: true, updatedAt: 1770000000000 }], + }); + render(); + + await waitFor(() => expect(screen.getByTestId("scratchnode-private-list")).toBeInTheDocument()); + expect(screen.getByTestId("scratchnode-private-list")).toHaveTextContent("PRIVATE_BODY_TEXT"); + expect(screen.getByTestId("scratchnode-private-cta")).toHaveAttribute("href", "/"); + // SECURITY: the opaque token must never be rendered anywhere on the page. + expect(document.body.innerHTML).not.toContain(TOKEN); + // consume was called with exactly the token (once). + expect(consumeMock).toHaveBeenCalledWith({ token: TOKEN }); + }); + + it("shows an honest expired state (never a fabricated note) for an expired token", async () => { + consumeMock.mockResolvedValue({ ok: false, reason: "expired" }); + render(); + await waitFor(() => expect(screen.getByTestId("scratchnode-private-error")).toBeInTheDocument()); + expect(screen.getByTestId("scratchnode-private-error")).toHaveTextContent("expired"); + expect(screen.queryByTestId("scratchnode-private-list")).toBeNull(); + expect(document.body.innerHTML).not.toContain(TOKEN); + }); + + it("treats a missing token as invalid without calling the backend", async () => { + render(); + await waitFor(() => expect(screen.getByTestId("scratchnode-private-error")).toBeInTheDocument()); + expect(consumeMock).not.toHaveBeenCalled(); + }); + + it("shows an honest empty state when the snapshot has no notes", async () => { + consumeMock.mockResolvedValue({ ok: true, eventName: "Rooftop", eventSlug: "rooftop", noteCount: 0, notes: [] }); + render(); + await waitFor(() => expect(screen.getByTestId("scratchnode-private-empty")).toBeInTheDocument()); + expect(screen.queryByTestId("scratchnode-private-list")).toBeNull(); + }); + + it("SANITIZES note bodies — script/handlers never reach the DOM (XSS defense)", async () => { + consumeMock.mockResolvedValue({ + ok: true, eventName: "Rooftop", eventSlug: "rooftop", noteCount: 1, + notes: [{ title: "x", bodyHtml: "

KEEP

", pinned: false, updatedAt: 1 }], + }); + const { container } = render(); + await waitFor(() => expect(screen.getByTestId("scratchnode-private-list")).toHaveTextContent("KEEP")); + expect(container.querySelector("script")).toBeNull(); + expect(screen.getByTestId("scratchnode-private-list").innerHTML).not.toContain("onerror"); + }); +}); diff --git a/src/features/events/views/ScratchnodePrivateBridge.tsx b/src/features/events/views/ScratchnodePrivateBridge.tsx new file mode 100644 index 00000000..b13d095b --- /dev/null +++ b/src/features/events/views/ScratchnodePrivateBridge.tsx @@ -0,0 +1,154 @@ +/** + * ScratchnodePrivateBridge — the NodeBench receiving surface for + * `/events/:slug/private?token=…`, the cross-domain private-notes handoff (#4). + * + * A ScratchNode member mints an opaque token on scratchnode.live (after a + * membership check) that carries a READ-ONLY snapshot of their private notes for + * that event. Only the opaque token travels in the URL. This surface consumes it + * fail-closed and renders the notes read-only, with a "sign in to keep these" + * affordance. It NEVER displays or logs the token, and the raw session id never + * existed on this side. + * + * Honesty: invalid / expired / used-up token → a real, specific empty state, + * never a fabricated note. bodyHtml is DOMPurify-sanitized before render. + * + * Prior art: mirrors src/features/events/views/ScratchnodeWikiBridge.tsx. + */ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useMutation } from "convex/react"; +import DOMPurify from "dompurify"; +import { api } from "../../../../convex/_generated/api"; + +type NoteSnapshot = { title: string; bodyHtml: string; pinned: boolean; updatedAt: number }; +type ConsumeResult = + | { ok: true; eventName: string; eventSlug: string; noteCount: number; notes: NoteSnapshot[] } + | { ok: false; reason: "invalid" | "expired" | "used_up" }; + +const SCRATCHNODE_ORIGIN = "https://scratchnode.live"; + +const REASON_COPY: Record = { + invalid: { head: "This handoff link isn’t valid.", sub: "It may be incomplete or already replaced. Open the room on ScratchNode and continue again." }, + expired: { head: "This handoff link has expired.", sub: "Private-notes links are short-lived for your security. Re-open the handoff from the room." }, + used_up: { head: "This handoff link has been used up.", sub: "For your security each link is single-use-ish. Re-open the handoff from the room." }, +}; + +function fmtDate(ms: number): string { + try { return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); } + catch { return ""; } +} + +interface Props { + slug: string; + token: string | null; +} + +export function ScratchnodePrivateBridge({ slug, token }: Props) { + const consume = useMutation((api as any).eventHandoff.consumeEventHandoffToken); + const [state, setState] = useState<"loading" | "ok" | "error">("loading"); + const [result, setResult] = useState(null); + const ran = useRef(false); + + useEffect(() => { + if (ran.current) return; + ran.current = true; + if (!token) { + setResult({ ok: false, reason: "invalid" }); + setState("error"); + return; + } + consume({ token }) + .then((r: ConsumeResult) => { + setResult(r); + setState(r && r.ok ? "ok" : "error"); + }) + .catch(() => { + setResult({ ok: false, reason: "invalid" }); + setState("error"); + }); + }, [token, consume]); + + const roomUrl = `${SCRATCHNODE_ORIGIN}/e/${encodeURIComponent(String(slug || "").toLowerCase())}`; + const notes = result && result.ok ? result.notes : []; + const safeNotes = useMemo( + () => notes.map((n) => ({ ...n, safeBody: n.bodyHtml ? DOMPurify.sanitize(n.bodyHtml) : "" })), + [notes], + ); + + return ( +
+
+
ScratchNode → NodeBench · private
+

+ {state === "ok" && result && result.ok ? `${result.eventName} — your notes` : "Your private notes"} +

+

+ Your private notes from this event, brought into NodeBench. Only you, with this + link, can see them — they’re read-only here. +

+
+ + {state === "loading" ? ( +
+ Unlocking your private notes… +
+ ) : state === "error" && result && !result.ok ? ( +
+

+ {(REASON_COPY[result.reason] || REASON_COPY.invalid).head} +

+

+ {(REASON_COPY[result.reason] || REASON_COPY.invalid).sub} +

+ + Open the room on ScratchNode → + +
+ ) : safeNotes.length === 0 ? ( +
+

+ No private notes for this event yet. +

+

+ Lock the composer in the room to jot a private note — it’ll be here next time. +

+ Open in ScratchNode → +
+ ) : ( + <> +
    + {safeNotes.map((n, i) => ( +
  • +
    + {n.title || "Untitled note"} + {n.pinned ? ( + Pinned + ) : null} + {fmtDate(n.updatedAt)} +
    +
    +
  • + ))} +
+
+

Keep these in NodeBench.

+

+ Sign in to save this event’s notes to your workspace — they become editable, searchable operating memory instead of a one-time read. +

+ +
+ + )} +
+ ); +} + +export default ScratchnodePrivateBridge; From 0943ea547fe49b7de1b5fa52eea79c3e6092b3a5 Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 14:08:20 -0700 Subject: [PATCH 2/2] @ fix(scratchnode): local ConvexError class in eventHandoff.ts (convex/values lacks it) CI tsc caught TS2305: this Convex version does not export ConvexError from convex/values (local tsc resolution masked it). Mirror the local class events.ts defines so thrown mint errors still carry a typed data.code. 8/8 adversarial tests still pass. Co-Authored-By: Claude Opus 4.8 (1M context) @ --- convex/eventHandoff.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/convex/eventHandoff.ts b/convex/eventHandoff.ts index 210f3dbe..12840be3 100644 --- a/convex/eventHandoff.ts +++ b/convex/eventHandoff.ts @@ -27,7 +27,19 @@ */ import { mutation } from "./_generated/server"; import { v } from "convex/values"; -import { ConvexError } from "convex/values"; + +// This Convex version does not export `ConvexError` from "convex/values" (CI tsc +// catches it even though some local resolutions don't), so mirror the local class +// convex/events.ts defines — a thrown error still carries a typed `data.code`. +class ConvexError> extends Error { + data: T; + constructor(data: T) { + super(String(data.message ?? JSON.stringify(data))); + this.name = "ConvexError"; + this.data = data; + (this as Record)[Symbol.for("ConvexError")] = true; + } +} const TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes — short by design const TOKEN_MAX_USES = 10; // tolerate retries / React strict-mode double-call, still bounded