Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion AGENT_COORDINATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
39 changes: 39 additions & 0 deletions CHANGELOG/pages/scratchnode-nodebench-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>/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
Expand Down
171 changes: 171 additions & 0 deletions convex/__tests__/scratchnode.handoffToken.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/// <reference types="vite/client" />
/**
* 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);
});
});
Loading
Loading