From 0cf7ddfb7274965287f4bc1cce47c377d679c585 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 05:32:20 +0000 Subject: [PATCH 1/7] feat(console): extract pure bunker-login helpers, unit-tested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NIP-46 sign-in's security-relevant logic (timeout bounding, signer-error → static-message mapping, auth_url sanitization, the key-material refusal gate) moves to a plain module the node test runner can import — the Svelte island cannot be. 15 tests pin the contracts, including: TimeoutError on a bunker that never answers, late-rejection swallowing, http(s)-only approval URLs (javascript:/data:/blob: dropped), and that bunkerReason never echoes attacker-influenceable signer text. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LVPyHAtQ1cdK76RpFasafP --- src/components/bunker-login.test.ts | 121 ++++++++++++++++++++++++++++ src/components/bunker-login.ts | 89 ++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 src/components/bunker-login.test.ts create mode 100644 src/components/bunker-login.ts diff --git a/src/components/bunker-login.test.ts b/src/components/bunker-login.test.ts new file mode 100644 index 0000000..0857770 --- /dev/null +++ b/src/components/bunker-login.test.ts @@ -0,0 +1,121 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + withTimeout, TimeoutError, safeApprovalUrl, bunkerReason, looksLikeKeyMaterial, + BUNKER_CONNECT_TIMEOUT_MS, BUNKER_SIGN_TIMEOUT_MS, +} from './bunker-login.ts'; + +// ---- withTimeout: every remote-signer await is bounded ---- + +test('withTimeout passes through a value that settles in time', async () => { + assert.equal(await withTimeout(Promise.resolve('ok'), 1000, 'op'), 'ok'); +}); + +test('withTimeout propagates the underlying rejection unchanged', async () => { + const boom = new Error('relay closed'); + await assert.rejects(withTimeout(Promise.reject(boom), 1000, 'op'), (e) => e === boom); +}); + +test('withTimeout rejects with TimeoutError when the promise never settles', async () => { + const never = new Promise(() => { /* a bunker that never answers */ }); + await assert.rejects(withTimeout(never, 10, 'bunker connect'), (e: unknown) => { + assert.ok(e instanceof TimeoutError); + assert.equal((e as TimeoutError).name, 'TimeoutError'); + assert.match((e as Error).message, /bunker connect/); + return true; + }); +}); + +test('withTimeout swallows a LATE rejection after timing out (no unhandled rejection)', async () => { + let rejectLate!: (e: Error) => void; + const late = new Promise((_, rej) => { rejectLate = rej; }); + await assert.rejects(withTimeout(late, 5, 'op'), TimeoutError); + rejectLate(new Error('late relay error')); + // Give the microtask queue a turn — an unhandled rejection here would fail the run. + await new Promise((r) => setTimeout(r, 10)); +}); + +test('timeout budgets are sane: bounded, and generous enough for a human approval tap', () => { + for (const ms of [BUNKER_CONNECT_TIMEOUT_MS, BUNKER_SIGN_TIMEOUT_MS]) { + assert.ok(ms >= 15_000 && ms <= 120_000, `budget ${ms} out of range`); + } +}); + +// ---- safeApprovalUrl: signer-provided auth_url is untrusted input ---- + +test('safeApprovalUrl accepts http(s) and returns the normalized href', () => { + assert.equal(safeApprovalUrl('https://nsec.app/approve?id=1'), 'https://nsec.app/approve?id=1'); + assert.equal(safeApprovalUrl('http://localhost:8080/ok'), 'http://localhost:8080/ok'); +}); + +test('safeApprovalUrl drops executable and non-web schemes', () => { + for (const bad of [ + // eslint-disable-next-line no-script-url + 'javascript:alert(1)', + 'data:text/html,', + 'blob:https://x/abc', + 'goose://recipe?config=x', + 'file:///etc/passwd', + 'vbscript:x', + ]) { + assert.equal(safeApprovalUrl(bad), null, `should drop ${bad}`); + } +}); + +test('safeApprovalUrl drops non-strings, unparseable strings, and oversized URLs', () => { + assert.equal(safeApprovalUrl(undefined), null); + assert.equal(safeApprovalUrl(42 as unknown), null); + assert.equal(safeApprovalUrl(''), null); + assert.equal(safeApprovalUrl('not a url'), null); + assert.equal(safeApprovalUrl('https://x.example/' + 'a'.repeat(2048)), null); +}); + +// ---- bunkerReason: static strings only, never echoing signer text ---- + +test('bunkerReason explains a timeout and the single-use secret rule', () => { + const m = bunkerReason(new TimeoutError('bunker connect', 60_000)); + assert.match(m, /didn’t answer in time/i); + assert.match(m, /single-use/i); +}); + +test('bunkerReason maps challenge/verify round-trip failures to their own lines', () => { + assert.match(bunkerReason(new Error('challenge')), /login challenge/i); + assert.match(bunkerReason(new Error('verify')), /allowlisted admin keys/i); +}); + +test('bunkerReason maps bunker rejections (incl. string rejections) to the fresh-string hint', () => { + // nostr-tools rejects with the bunker's error STRING verbatim — not an Error. + assert.match(bunkerReason('invalid secret'), /single-use/i); + assert.match(bunkerReason(new Error('already connected')), /fresh/i); + assert.match(bunkerReason('unauthorized'), /single-use/i); +}); + +test('bunkerReason NEVER echoes attacker-influenceable error text', () => { + const hostile = ' secret'; + for (const e of [new Error(hostile), hostile]) { + const m = bunkerReason(e); + assert.ok(!m.includes('<'), 'message must not contain markup from the error'); + assert.ok(!m.includes('img'), 'message must not echo error content'); + } +}); + +test('bunkerReason falls back to the generic explanation for unknown failures', () => { + assert.match(bunkerReason(new Error('ECONNRESET')), /didn’t complete/i); + assert.match(bunkerReason(null), /didn’t complete/i); +}); + +// ---- looksLikeKeyMaterial: the nsec refusal gate (unchanged behavior, now pinned) ---- + +test('looksLikeKeyMaterial refuses nsec, ncryptsec, and raw 64-hex', () => { + assert.ok(looksLikeKeyMaterial('nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq')); + assert.ok(looksLikeKeyMaterial(' NSEC1ABC '), 'case- and whitespace-insensitive'); + assert.ok(looksLikeKeyMaterial('ncryptsec1xyz')); + assert.ok(looksLikeKeyMaterial('a'.repeat(64))); + assert.ok(looksLikeKeyMaterial('0123456789abcdef'.repeat(4))); +}); + +test('looksLikeKeyMaterial allows bunker strings and NIP-05 names', () => { + assert.ok(!looksLikeKeyMaterial('bunker://abc123?relay=wss://relay.example&secret=s')); + assert.ok(!looksLikeKeyMaterial('admin@example.com')); + assert.ok(!looksLikeKeyMaterial('npub1kdve3xh5l4xnuwx87zff9pyrq044kx9pd6lsxwdfgpy0xklhv5qs96rgqv'), 'npub is PUBLIC — not key material'); +}); diff --git a/src/components/bunker-login.ts b/src/components/bunker-login.ts new file mode 100644 index 0000000..09f2611 --- /dev/null +++ b/src/components/bunker-login.ts @@ -0,0 +1,89 @@ +/** + * bunker-login.ts — pure helpers for the /console/ NIP-46 remote-bunker sign-in + * (AdminConsole.svelte). Extracted so the security-relevant logic is unit-tested: + * the Svelte island can't be imported by the node test runner, a plain module can. + * + * Design rules (these are the fix for the "silent bunker hang" class of failure): + * - Every remote-signer await is BOUNDED (withTimeout) — an unanswered on-phone + * approval or a dead relay must become a visible, explained error, never a + * spinner that lives forever. + * - Error → message mapping (bunkerReason) returns STATIC strings only. Bunker + * and relay errors are attacker-influenceable text; they are matched against, + * never echoed into the DOM. + * - Approval URLs (NIP-46 `auth_url`, used by web bunkers like nsec.app; Amber + * and other push-approval signers never send one) are signer-provided input: + * sanitized to http(s) and length-capped before the UI may open or render one. + * - Anything that looks like key material is refused before any of this runs. + */ + +/** Bound on the bunker `connect()` round-trip — includes the human tapping + * "approve" on their signer, so generous; but never infinite. */ +export const BUNKER_CONNECT_TIMEOUT_MS = 60_000; +/** Bound on the remote `signEvent()` round-trip (a second approval on most signers). */ +export const BUNKER_SIGN_TIMEOUT_MS = 60_000; + +export class TimeoutError extends Error { + constructor(what: string, ms: number) { + super(`${what} timed out after ${Math.round(ms / 1000)}s`); + this.name = 'TimeoutError'; + } +} + +/** Resolve/reject with `p`, or reject with TimeoutError after `ms`. On timeout the + * late settlement of `p` is swallowed so it can never surface as an unhandled + * rejection minutes later. */ +export function withTimeout(p: Promise, ms: number, what: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + p.catch(() => { /* late failure after timeout — already reported */ }); + reject(new TimeoutError(what, ms)); + }, ms); + p.then( + (v) => { clearTimeout(timer); resolve(v); }, + (e) => { clearTimeout(timer); reject(e); }, + ); + }); +} + +/** Sanitize a signer-provided NIP-46 `auth_url` before the UI opens or renders it. + * http(s) only — never javascript:, data:, blob:, or custom schemes — and + * length-capped. Returns the normalized href, or null to drop it. */ +export function safeApprovalUrl(raw: unknown): string | null { + if (typeof raw !== 'string' || raw.length === 0 || raw.length > 2048) return null; + let url: URL; + try { + url = new URL(raw); + } catch { + return null; + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') return null; + return url.href; +} + +/** + * Map a bunker sign-in failure to a human explanation. STATIC strings only — + * `e` may carry relay/bunker-authored text (nostr-tools rejects with the bunker's + * error string verbatim), so it is pattern-matched, never rendered. + */ +export function bunkerReason(e: unknown): string { + if (e instanceof TimeoutError) { + return 'The bunker didn’t answer in time. Approve the request in your signer app and try again — ' + + 'and note that a bunker:// secret is SINGLE-USE: paste a fresh connection string for each attempt.'; + } + const msg = typeof e === 'string' ? e : e instanceof Error ? e.message : ''; + if (msg === 'challenge') return 'Couldn’t get a login challenge from the server — try again in a moment.'; + if (msg === 'verify') return 'The server refused the sign-in. Only allowlisted admin keys are accepted.'; + if (/secret|already|unauthorized|forbidden|denied|reject|invalid/i.test(msg)) { + return 'The bunker refused the connection. bunker:// secrets are single-use — ' + + 'get a FRESH connection string from your signer app and try again.'; + } + return 'Bunker sign-in didn’t complete. Check the connection string, approve the request in your signer, ' + + 'and make sure only allowlisted admin keys are used.'; +} + +/** Anything that looks like key material is refused outright — the console's + * bunker input is ONLY for a bunker:// connection string or a NIP-05 name. */ +export function looksLikeKeyMaterial(s: string): boolean { + const t = s.trim().toLowerCase(); + return t.startsWith('nsec1') || t.startsWith('ncryptsec1') || /^[0-9a-f]{64}$/.test(t); +} From caf60e329a775f0d9e20c2dd407da6514f5ab527 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 05:33:19 +0000 Subject: [PATCH 2/7] fix(console): bound the NIP-46 bunker round-trips and surface the real failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two silent-failure defects behind 'bunker sign-in just spins': - connect() and the remote signEvent() had NO timeout — an unanswered on-phone approval or a dead relay hung the UI forever. Both awaits are now bounded (60s each, generous for a human approval tap but never infinite). - the bare catch discarded the actual error. It now goes to console.error as the operator's diagnostic (error object only — never the pasted input, never key material), and the UI message is mapped via bunkerReason() from static strings: timeout → approve-and-retry + the single-use-secret rule; bunker rejection → fresh bunker:// string hint; verify → allowlist refusal. signer.close() is now fire-and-forget so cleanup can never hang the UI either. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LVPyHAtQ1cdK76RpFasafP --- src/components/AdminConsole.svelte | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/components/AdminConsole.svelte b/src/components/AdminConsole.svelte index 558d133..175f7da 100644 --- a/src/components/AdminConsole.svelte +++ b/src/components/AdminConsole.svelte @@ -27,6 +27,10 @@ * the authority. */ import { onMount } from 'svelte'; + import { + looksLikeKeyMaterial, withTimeout, bunkerReason, + BUNKER_CONNECT_TIMEOUT_MS, BUNKER_SIGN_TIMEOUT_MS, + } from './bunker-login.ts'; type Role = 'superadmin' | 'admin'; type Who = { identity: string; method: 'nostr' | 'bluesky'; role: Role; csrf?: string }; @@ -133,12 +137,8 @@ } } - /** Anything that looks like key material is refused outright — this input is - * ONLY for a bunker connection string (bunker://…) or a NIP-05 name. */ - function looksLikeKeyMaterial(s: string): boolean { - const t = s.trim().toLowerCase(); - return t.startsWith('nsec1') || t.startsWith('ncryptsec1') || /^[0-9a-f]{64}$/.test(t); - } + // The key-material refusal gate + all other pure NIP-46 logic live in + // ./bunker-login.ts, where they are unit-tested. async function loginNip46() { error = ''; notice = ''; @@ -163,14 +163,21 @@ // never the admin's identity key). const s = BunkerSigner.fromBunker(generateSecretKey(), pointer); signer = s; - await s.connect(); - await loginWithSigner((tpl) => s.signEvent(tpl)); + // Both remote awaits are BOUNDED: an unanswered on-phone approval or a dead + // relay becomes a visible, explained error — never a spinner that lives forever. + await withTimeout(s.connect(), BUNKER_CONNECT_TIMEOUT_MS, 'bunker connect'); + await loginWithSigner((tpl) => withTimeout(s.signEvent(tpl), BUNKER_SIGN_TIMEOUT_MS, 'bunker signing')); notice = ''; - } catch { - error = 'Bunker sign-in didn’t complete. Check the connection string, approve the request in your signer, and make sure only allowlisted admin keys are used.'; + } catch (e) { + // The real cause goes to the console for the operator — the error object + // only, never the pasted input and never key material. The UI gets a + // static explanation mapped from it (bunkerReason echoes nothing). + console.error('[admin] NIP-46 bunker login failed:', e); + error = bunkerReason(e); notice = ''; } finally { - try { await signer?.close(); } catch { /* relay already closed */ } + // Fire-and-forget: close() itself must never be able to hang the UI. + signer?.close().catch(() => { /* relay already closed */ }); busy = ''; } } From 972cafbed5638b71e533fce94e3c593f75670d90 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 05:34:28 +0000 Subject: [PATCH 3/7] feat(console): handle the NIP-46 auth_url approval step (web bunkers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web bunkers (nsec.app-style) reply to connect with an auth_url the user must open and approve; without an onauth handler nostr-tools only warns and the connect promise never settles — the third silent-hang path. Amber and other push-approval signers never send one, so this changes nothing for them. The URL is signer-provided input and is treated as such: sanitized via safeApprovalUrl (http/https only, length-capped — javascript:/data:/blob: dropped), opened with noopener,noreferrer, and rendered as a clickable fallback link for the common case where the popup is blocked (the open runs outside the click gesture). The link lives only while the attempt is in flight and is cleared once the signer closes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LVPyHAtQ1cdK76RpFasafP --- src/components/AdminConsole.svelte | 33 +++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/components/AdminConsole.svelte b/src/components/AdminConsole.svelte index 175f7da..b324bef 100644 --- a/src/components/AdminConsole.svelte +++ b/src/components/AdminConsole.svelte @@ -10,6 +10,9 @@ * src/lib/security-headers.ts). The bunker connection string is pasted * here; an ephemeral client key is generated in-memory for transport. * NEVER an nsec/ncryptsec input — key-looking pastes are rejected. + * Web bunkers' auth_url approval step is supported (sanitized, http(s) + * only); every remote await is timeout-bounded and failures surface + * their real cause in the browser console (see ./bunker-login.ts). * - Bluesky: reuse the ordinary site sign-in (header widget), then elevate; * the server checks the session's proven DID against the allowlist. * @@ -28,7 +31,7 @@ */ import { onMount } from 'svelte'; import { - looksLikeKeyMaterial, withTimeout, bunkerReason, + looksLikeKeyMaterial, withTimeout, bunkerReason, safeApprovalUrl, BUNKER_CONNECT_TIMEOUT_MS, BUNKER_SIGN_TIMEOUT_MS, } from './bunker-login.ts'; @@ -51,6 +54,10 @@ let notice = $state(''); let busy = $state<'' | 'nip07' | 'nip46' | 'bluesky' | 'logout'>(''); let bunkerInput = $state(''); + // Web bunkers (nsec.app-style) send a NIP-46 `auth_url` the user must open and + // approve; Amber and other push-approval signers never do. Sanitized before it + // may render — live only while the attempt is in flight. + let approvalUrl = $state(''); // ---- management panel state (superadmin only) ---- let admins = $state(null); @@ -141,7 +148,7 @@ // ./bunker-login.ts, where they are unit-tested. async function loginNip46() { - error = ''; notice = ''; + error = ''; notice = ''; approvalUrl = ''; const input = bunkerInput.trim(); if (!input) { error = 'Paste your bunker:// connection string first.'; return; } if (looksLikeKeyMaterial(input)) { @@ -161,7 +168,19 @@ notice = 'Connecting to your bunker — approve the request in your signer app…'; // Ephemeral TRANSPORT key for this conversation only (never persisted, // never the admin's identity key). - const s = BunkerSigner.fromBunker(generateSecretKey(), pointer); + const s = BunkerSigner.fromBunker(generateSecretKey(), pointer, { + // The auth_url handler web bunkers require: without it nostr-tools warns + // and the connect promise never settles. The URL is SIGNER-PROVIDED input: + // sanitize to http(s) before opening, open with no opener access, and keep + // a clickable fallback rendered for when the popup is blocked (this runs + // outside the click gesture, so blockers usually do block it). + onauth: (url: string) => { + const safe = safeApprovalUrl(url); + if (!safe) return; + approvalUrl = safe; + window.open(safe, '_blank', 'noopener,noreferrer'); + }, + }); signer = s; // Both remote awaits are BOUNDED: an unanswered on-phone approval or a dead // relay becomes a visible, explained error — never a spinner that lives forever. @@ -178,6 +197,7 @@ } finally { // Fire-and-forget: close() itself must never be able to hang the UI. signer?.close().catch(() => { /* relay already closed */ }); + approvalUrl = ''; // dead once the signer is closed, whatever the outcome busy = ''; } } @@ -427,6 +447,13 @@ {busy === 'nip46' ? 'Waiting for bunker…' : 'Connect'} + {#if approvalUrl} +

+ Your bunker asks you to approve this connection: + open the approval page, + approve there, then return to this tab. +

+ {/if}

Bluesky

From 986774559c25dad5356bd84cf6255a76a36c37dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 05:35:42 +0000 Subject: [PATCH 4/7] feat(studio): receipts travel into the downloaded artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'receipts travel' half of the connective-spine Phase 2 stopped at the screen edge: the license-at-commit receipt rendered in the blueprint UI and persisted into session.stack, but the files the builder actually keeps — README.md and AGENT_PROMPT.txt — omitted it. A new pure, tested helper (receiptLine) renders the same facts into both: linked in the README, plain in the agent prompt. Entries with no pinned commit get an honest 'verification pending' note, never a fabricated claim. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LVPyHAtQ1cdK76RpFasafP --- src/components/BuildStudio.svelte | 6 +++--- src/lib/studio-stack.test.ts | 30 +++++++++++++++++++++++++++++- src/lib/studio-stack.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/components/BuildStudio.svelte b/src/components/BuildStudio.svelte index 4ec8d6f..2e34cea 100644 --- a/src/components/BuildStudio.svelte +++ b/src/components/BuildStudio.svelte @@ -6,7 +6,7 @@ import type { ExcludedOrg, Ecosystem } from '../../enforcement/types.ts'; import { detectSignals, pickQuestions, reflect, reflectFromResponse, type ConstraintId } from '../lib/mentor-engine.ts'; import { chemistry, partnersOf } from '../lib/chemistry.ts'; - import { eligibleForStack, advisoryRank, autoPickable, pinnedDependencies } from '../lib/studio-stack.ts'; + import { eligibleForStack, advisoryRank, autoPickable, pinnedDependencies, receiptLine } from '../lib/studio-stack.ts'; import { slugifySkill, skillToMd, type DraftSkill } from '../lib/skill-doc.ts'; import { mentorPersonaSkill } from '../lib/mentor-persona.ts'; import { buildGooseRecipe, recipeToYaml, skillToSubRecipe, skillSubRecipePath, type ExtensionAllowlist } from '../lib/goose-recipe.ts'; @@ -748,7 +748,7 @@ RULES (binding — see constitution.md): - Read .specify/memory/constitution.md FIRST and never violate it. - Read skills/*.SKILL.md and follow the builder's own methods exactly; if a skill conflicts with a task, surface it and ask. - Use ONLY these policy-clean dependencies (no Meta/OpenAI/xAI, screened by enforcement). ★ = human-verified; the rest passed automated policy screening and are pending verification — prefer ★ where a choice exists: -${chosenItems.map((it) => ` - ${it.verification === 'verified' ? '★ ' : ''}${it.name} (${it.ecosystem})${it.advisory ? ` [${it.advisory}-origin advisory]` : ''}`).join('\n') || ' - '} +${chosenItems.map((it) => ` - ${it.verification === 'verified' ? '★ ' : ''}${it.name} (${it.ecosystem})${it.advisory ? ` [${it.advisory}-origin advisory]` : ''}${receiptLine(it, 'plain')}`).join('\n') || ' - '} ${protocols.has('nostr') ? '- For Nostr, use @nostr-dev-kit/ndk (NDK) as the primary SDK for relays, subscriptions, and signers.\n' : ''}${protocols.has('atproto') ? '- For AT Protocol, use @atproto/api as the primary SDK; prefer OAuth (DPoP) over App Passwords.\n' : ''}- No dependency or provider owned by Meta, OpenAI, or xAI — directly or transitively. - Run \`npm run enforce\` before every commit. Add rate limiting, test auth paths, and never swallow trust-path errors. @@ -781,7 +781,7 @@ Scaffolded by wecanjustbuildthings.dev — every dependency is screened against Meta/OpenAI/xAI exclusion policy. ## Stack -${chosenItems.map((it) => `- [${it.name}](${it.repo || it.url}) — ${it.desc}`).join('\n') || '- (none)'} +${chosenItems.map((it) => `- [${it.name}](${it.repo || it.url}) — ${it.desc}${receiptLine(it, 'markdown')}`).join('\n') || '- (none)'} ## Build it with an agent 1. Configure your agent (Goose or Claude Code) with a permitted, BYOK provider. diff --git a/src/lib/studio-stack.test.ts b/src/lib/studio-stack.test.ts index 54e509e..22c58bf 100644 --- a/src/lib/studio-stack.test.ts +++ b/src/lib/studio-stack.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import yaml from 'js-yaml'; -import { eligibleForStack, autoPickable, advisoryRank, pinnedDependencies } from './studio-stack.ts'; +import { eligibleForStack, autoPickable, advisoryRank, pinnedDependencies, receiptLine } from './studio-stack.ts'; test('pinnedDependencies pins to each entry\'s recorded version, not "latest" (#3)', () => { const deps = [ @@ -60,6 +60,34 @@ test('advisoryRank orders advisory tools last', () => { assert.equal(advisoryRank({ advisory: 'meta' }), 1); }); +// ---- Movement 2: receipts travel into the DOWNLOADED artifacts ---- + +test('receiptLine renders the license-at-commit claim, sha shortened to 7', () => { + const r = { license: 'MIT', commit: '58b5ca6f00e30a9d1c9e2b7d', licenseUrl: 'https://x.example/LICENSE' }; + assert.equal(receiptLine(r, 'plain'), ' — license MIT verified at 58b5ca6'); + assert.equal(receiptLine(r, 'markdown'), ' — [license MIT verified at 58b5ca6](https://x.example/LICENSE)'); +}); + +test('receiptLine markdown without a source URL falls back to unlinked text', () => { + assert.equal( + receiptLine({ license: 'MIT', commit: 'abcdef1234' }, 'markdown'), + ' — license MIT verified at abcdef1', + ); +}); + +test('receiptLine never fabricates: no commit → honest pending note (or nothing)', () => { + // license recorded but not pinned at a commit → pending, no "verified" claim + assert.equal(receiptLine({ license: 'MIT', commit: null }, 'plain'), ' — license MIT (verification pending)'); + assert.equal(receiptLine({ license: 'MIT' }, 'markdown'), ' — license MIT (verification pending)'); + // nothing recorded → nothing claimed + assert.equal(receiptLine({}, 'plain'), ''); + assert.equal(receiptLine({ license: ' ', commit: '' }, 'markdown'), ''); +}); + +test('receiptLine tolerates a missing license on a pinned commit', () => { + assert.equal(receiptLine({ commit: '1234567890' }, 'plain'), ' — license verified at 1234567'); +}); + // ---- Phase 5: the Marmot "secure messaging" stack assembles in Build Studio ---- // Build Studio fetches /catalog.json (src/pages/catalog.json.ts maps it from the // catalog content collection) and keeps only eligibleForStack() entries. This test diff --git a/src/lib/studio-stack.ts b/src/lib/studio-stack.ts index c68638c..5b92ece 100644 --- a/src/lib/studio-stack.ts +++ b/src/lib/studio-stack.ts @@ -64,3 +64,31 @@ export function pinnedDependencies( }), ); } + +/** The license-at-commit facts a catalog entry carries (subset of the Studio's + * Item / the session receipt). */ +export interface ReceiptFacts { + license?: string | null; + commit?: string | null; + licenseUrl?: string | null; +} + +/** + * Movement 2, "receipts travel" — the license-at-commit receipt rendered for the + * DOWNLOADED artifacts (README.md, AGENT_PROMPT.txt), so the evidence shown in + * the blueprint UI travels into the files the builder keeps, not just the screen. + * Pure + total: an entry with no pinned commit yields an honest "verification + * pending" note (or nothing), never a fabricated claim. + * - 'markdown' (README): links the claim to the recorded license source. + * - 'plain' (AGENT_PROMPT): the same facts without markup. + */ +export function receiptLine(r: ReceiptFacts, style: 'markdown' | 'plain'): string { + const license = r.license?.trim(); + const sha = r.commit?.trim(); + if (!sha) return license ? ` — license ${license} (verification pending)` : ''; + const label = license + ? `license ${license} verified at ${sha.slice(0, 7)}` + : `license verified at ${sha.slice(0, 7)}`; + if (style === 'markdown' && r.licenseUrl) return ` — [${label}](${r.licenseUrl})`; + return ` — ${label}`; +} From a6a85339d0664121e3ee450324ee4f8d01afc998 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 05:37:14 +0000 Subject: [PATCH 5/7] feat(studio): surface and gate on the in-browser policy re-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit policyClean/policyMatches were computed since the Phase-2 merge but had zero consumers — the 'live guarantee that the handoff is policy-clean' produced no observable effect. Now: - blueprint step: a ✓ receipt line when the re-check is clean; a conflict alert listing name → org when it is not (silent when /policy.json didn't load — no claim either way, the build-time CI engine stays the authority). - handoff step: an excluded-vendor match PAUSES the handoff — no zip, no GitHub push, no Goose launch — until the stack is clean. Unreachable in practice (the catalog is enforced at build); this is the belt-and-suspenders the receipts promise. Localized en/es/ar. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LVPyHAtQ1cdK76RpFasafP --- src/components/BuildStudio.svelte | 32 +++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/components/BuildStudio.svelte b/src/components/BuildStudio.svelte index 2e34cea..877753e 100644 --- a/src/components/BuildStudio.svelte +++ b/src/components/BuildStudio.svelte @@ -44,6 +44,7 @@ handoff: 'How do you want to get started?', zip: 'Download a starter folder', github: 'Save it to GitHub', goose: 'Run it with Goose', dlzip: '⬇ Download your starter folder (.zip)', copyPrompt: 'Copy the instructions for your AI agent', + policyClean: 'Policy re-checked in your browser — every piece is clean of Meta, OpenAI, and xAI.', policyHit: 'Excluded-vendor match — handoff paused.', policyHitDetail: 'These tools match the exclusion policy. Remove them from your stack to continue:', runLocal: 'New to Goose? Start here →', ghGuide: 'How to connect GitHub →', ghError: 'Connecting to GitHub didn’t finish. Try again, or download the folder below.', handoffIntro: 'Your starter is ready — everything your AI agent needs to begin, with the rules and safe tools already baked in. Pick how you’d like to take it:', @@ -93,6 +94,7 @@ handoff: '¿Cómo quieres empezar?', zip: 'Descargar una carpeta inicial', github: 'Guardarlo en GitHub', goose: 'Ejecutarlo con Goose', dlzip: '⬇ Descargar tu carpeta inicial (.zip)', copyPrompt: 'Copiar las instrucciones para tu agente de IA', + policyClean: 'Política re-verificada en tu navegador — todas las piezas están libres de Meta, OpenAI y xAI.', policyHit: 'Coincidencia con un proveedor excluido — entrega en pausa.', policyHitDetail: 'Estas herramientas coinciden con la política de exclusión. Quítalas de tu stack para continuar:', runLocal: '¿Nuevo en Goose? Empieza aquí →', ghGuide: 'Cómo conectar GitHub →', ghError: 'La conexión con GitHub no terminó. Inténtalo de nuevo o descarga la carpeta abajo.', handoffIntro: 'Tu kit está listo — todo lo que tu agente de IA necesita para empezar, con las reglas y las herramientas seguras ya incluidas. Elige cómo quieres llevarlo:', @@ -142,6 +144,7 @@ handoff: 'كيف تريد أن تبدأ؟', zip: 'تنزيل مجلد بداية', github: 'احفظه في GitHub', goose: 'شغّله مع Goose', dlzip: '⬇ نزّل مجلد البداية (.zip)', copyPrompt: 'انسخ تعليمات وكيل الذكاء الاصطناعي', + policyClean: 'أُعيد فحص السياسة في متصفحك — كل القطع خالية من Meta وOpenAI وxAI.', policyHit: 'تطابق مع مزوّد مستبعد — التسليم متوقف.', policyHitDetail: 'هذه الأدوات تطابق سياسة الاستبعاد. أزلها من مجموعتك للمتابعة:', runLocal: 'جديد على Goose؟ ابدأ هنا ←', ghGuide: 'كيفية ربط GitHub ←', ghError: 'لم يكتمل الاتصال بـ GitHub. حاول مرة أخرى، أو نزّل المجلد أدناه.', handoffIntro: 'حزمتك جاهزة — كل ما يحتاجه وكيل الذكاء الاصطناعي للبدء، مع القواعد والأدوات الآمنة مُضمّنة سلفاً. اختر كيف تريد أخذها:', @@ -472,8 +475,11 @@ }); // Re-run the shared exclusion policy on the assembled stack, in the browser — // the same matchDependency() the dependency checker and the CLI engine use. - // The catalog is already enforced at build, so this should always be clean; it - // is the live guarantee that the handoff the builder downloads is policy-clean. + // The catalog is already enforced at build, so this should always be clean. + // The result is SURFACED (a ✓ badge in the blueprint) and ENFORCED (a match + // pauses the handoff step) — the live guarantee that what the builder downloads + // is policy-clean. If /policy.json can't load, policyOrgs stays empty and no + // claim is made either way; the build-time CI engine remains the authority. const policyMatches = $derived.by(() => { if (!policyOrgs.length) return [] as Array<{ name: string; org: string }>; const hits: Array<{ name: string; org: string }> = []; @@ -1145,6 +1151,16 @@ manuals with the knowledge-to-skills-pipeline).
    {#each chem.conflicts as c (c.a + c.b)}
  • {c.a} · {c.b}
  • {/each}
{/if} + + {#if policyClean} +

{t.policyClean}

+ {:else if policyMatches.length} + + {/if}
{t.refineTitle} @@ -1222,6 +1238,17 @@ manuals with the knowledge-to-skills-pipeline).

{t.handoff}

{t.handoffIntro}

{t.skillsHint}

+ + {#if policyMatches.length} + + {:else}
@@ -1278,6 +1305,7 @@ manuals with the knowledge-to-skills-pipeline).

{t.gooseDesc} {t.runLocal}

{/if} + {/if} From 439456bc8e0e8bd916c37ae4c023a99b37d8c009 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 06:10:20 +0000 Subject: [PATCH 6/7] fix(studio): close the policy gate over the clipboard channels too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adversarial review caught the gate leaking: the 'Copy the instructions for your AI agent' button and the constitution/spec/package.json copy viewers sat OUTSIDE the {#if policyMatches.length} gate, so with an excluded-vendor match present the UI said 'handoff paused' while one click still copied an agent prompt asserting the flagged tool was policy-clean and a package.json pinning it. A clipboard is as much a handoff channel as a zip — the gate now wraps all of them. Also: the ✓ 'policy re-checked' badge is only claimable over a NON-EMPTY stack — a failed catalog load or an emptied blueprint no longer renders a vacuous clean claim. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LVPyHAtQ1cdK76RpFasafP --- src/components/BuildStudio.svelte | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/BuildStudio.svelte b/src/components/BuildStudio.svelte index 877753e..587b415 100644 --- a/src/components/BuildStudio.svelte +++ b/src/components/BuildStudio.svelte @@ -490,7 +490,9 @@ } return hits; }); - const policyClean = $derived(policyOrgs.length > 0 && policyMatches.length === 0); + // "Clean" is only claimable over a NON-EMPTY stack — a failed catalog load or + // an emptied blueprint must not render a vacuous ✓. + const policyClean = $derived(policyOrgs.length > 0 && chosenItems.length > 0 && policyMatches.length === 0); // ---------- Movement 1: the live Mentor Engine (deterministic) ---------- // Localized prompts/options/constraint phrases for the engine's IDs. The logic @@ -1305,8 +1307,10 @@ manuals with the knowledge-to-skills-pipeline).

{t.gooseDesc} {t.runLocal}

{/if} - {/if} +

Want to look inside first? These are the files in your starter — the plain-English rules and plan your agent will follow:

@@ -1316,6 +1320,7 @@ manuals with the knowledge-to-skills-pipeline). {@render artifact('The project’s rules (constitution.md)', 'c', constitution)} {@render artifact('The plan (spec.md)', 's', spec)} {@render artifact('The tools list (package.json)', 'pkg', packageJson)} + {/if} From 0235195bc1418c101f7e42a493afc3801b3dd11e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 06:28:09 +0000 Subject: [PATCH 7/7] =?UTF-8?q?docs(admin):=20Phase-3=20storage=20schema?= =?UTF-8?q?=20as=20a=20reviewable=20draft=20=E2=80=94=20nothing=20provisio?= =?UTF-8?q?ned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADMIN_DB schema (staged catalog/content edits, moderation cases + NIP-32 labels, insert-only action audit) lands as code FIRST so it is reviewed before any resource exists: no wrangler.jsonc binding, no database created, no code path reads it. The file header carries the exact three operator steps that bring it live after review. Shape encodes the project's values: public identifiers only (never session tokens or key material), minimal references instead of content copies, insert-only audit (an editable audit trail is theater), staged drafts that expire rather than accumulate, labels stored 1:1 as NIP-32/NIP-69 events so portability stays a switchable mirror, and CSAM evidence explicitly excluded (Phase 6's locked R2 bucket, never this database). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LVPyHAtQ1cdK76RpFasafP --- migrations-admin/0001_admin_storage.sql | 107 ++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 migrations-admin/0001_admin_storage.sql diff --git a/migrations-admin/0001_admin_storage.sql b/migrations-admin/0001_admin_storage.sql new file mode 100644 index 0000000..a57f7da --- /dev/null +++ b/migrations-admin/0001_admin_storage.sql @@ -0,0 +1,107 @@ +-- 0001_admin_storage.sql — DRAFT (admin-panel Phase 3): the ADMIN_DB schema. +-- +-- ⚠ NOT YET PROVISIONED OR BOUND. This file is the reviewable spec for the +-- separate ADMIN_DB database the admin-panel spec calls for. Nothing reads it +-- yet: there is no ADMIN_DB binding in wrangler.jsonc and no code path touches +-- these tables. It lands first so the schema is reviewed as code BEFORE any +-- resource exists. To bring it live (operator, after review): +-- +-- 1. npx wrangler d1 create wcjbt-admin +-- 2. add to wrangler.jsonc d1_databases: +-- { "binding": "ADMIN_DB", "database_name": "wcjbt-admin", +-- "database_id": "", "migrations_dir": "migrations-admin" } +-- 3. npx wrangler d1 migrations apply wcjbt-admin --remote +-- +-- WHY A SEPARATE DATABASE (not more tables in wcjbt-auth): the runtime roster + +-- roster audit (migrations/0002) are auth-adjacent — WHO may sign in — and +-- correctly live with auth. Everything here is a different domain with a +-- different retention and legal posture: moderation casework and content +-- staging. Separation keeps the auth store small and auditable, and lets this +-- data carry its own lifecycle without ever touching sign-in state. +-- +-- VALUES ENCODED IN THE SHAPE (empower + protect the builder/operator): +-- - Data minimization: rows hold public identifiers ('provider:subject' — +-- pubkeys/DIDs) and reference tokens ONLY. Never a session token, never +-- key material, never more of a report than the case needs. +-- - admin_action_audit is INSERT-ONLY by contract, mirroring admin_audit in +-- migrations/0002: no code path may UPDATE or DELETE it (pin statically in +-- tests, as worker/tests/admin-roster.test.ts does for 0002). An editable +-- audit trail is theater. +-- - Staged edits EXPIRE (expires_at is NOT NULL): staging is a workbench, +-- not an archive. Abandoned drafts age out instead of accumulating. +-- - CSAM/NCMEC evidence NEVER lives here. Phase 6 puts evidence in the +-- locked-down ADMIN_EVIDENCE R2 bucket with its own access logging; this +-- database holds only case coordination. +-- - Portability without lock-in (Phase 4): mod_labels maps 1:1 onto NIP-32 +-- kind-1985 label events (MOD/X-MOD vocab per NIP-69), so Cloudflare stays +-- primary and the protocol layer is a switchable, default-off mirror. + +-- ---- Catalog / content staging (admin-panel Phase 5 reads/writes this) ---- +-- A draft catalog entry, skill, or guide edit being prepared in the console. +-- Publishing NEVER writes content directly to the site: the publish action runs +-- the enforcement engine, then opens a repository PR (the existing contribution +-- path) — state marks where in that flow the draft sits. +CREATE TABLE staged_edits ( + id TEXT PRIMARY KEY, -- opaque random id + created_at INTEGER NOT NULL, -- epoch ms + updated_at INTEGER NOT NULL, + author TEXT NOT NULL, -- acting admin, 'provider:subject' + kind TEXT NOT NULL, -- 'catalog-entry' | 'skill' | 'guide' + slug TEXT NOT NULL, -- target slug/path being created or edited + content TEXT NOT NULL, -- the full draft body (MDX/YAML) + enforcement_status TEXT NOT NULL, -- 'pending' | 'pass' | 'fail' + enforcement_report TEXT, -- engine findings (JSON), shown to the author + state TEXT NOT NULL, -- 'draft' | 'ready' | 'pr-opened' | 'abandoned' + pr_url TEXT, -- once opened, the PR is the source of truth + expires_at INTEGER NOT NULL -- drafts age out; staging is not archival +); +CREATE INDEX idx_staged_edits_author ON staged_edits(author); +CREATE INDEX idx_staged_edits_expires ON staged_edits(expires_at); + +-- ---- Trust & Safety casework (admin-panel Phase 6 reads/writes this) ---- +-- One row per report/incident under review. subject_ref is a minimal reference +-- (URL, event id, or slug) — the case holds coordinates, not copies of content. +CREATE TABLE mod_cases ( + id TEXT PRIMARY KEY, -- opaque random id + opened_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + source TEXT NOT NULL, -- 'report' | 'sweep' | 'manual' + subject_ref TEXT NOT NULL, -- what is under review (reference only) + reason TEXT NOT NULL, -- vocab token (NIP-56/NIP-69 style), not free prose + state TEXT NOT NULL, -- 'open' | 'reviewing' | 'actioned' | 'dismissed' + assignee TEXT, -- reviewing admin, 'provider:subject' + escalated INTEGER NOT NULL DEFAULT 0 -- 1 = routed to the restricted Phase-6 path +); +CREATE INDEX idx_mod_cases_state ON mod_cases(state, updated_at); + +-- The deterministic moderation outcome: label rows shaped as NIP-32 kind-1985 +-- events (namespace MOD/X-MOD per NIP-69) so the Phase-4 exporter can publish +-- them verbatim when portability is switched on. Per NIP-69, later moderation +-- supersedes earlier — superseded_by links forward instead of rewriting history. +CREATE TABLE mod_labels ( + id TEXT PRIMARY KEY, -- opaque random id + case_id TEXT NOT NULL REFERENCES mod_cases(id), + created_at INTEGER NOT NULL, + actor TEXT NOT NULL, -- labeling admin, 'provider:subject' + namespace TEXT NOT NULL, -- 'MOD' | 'X-MOD' + label TEXT NOT NULL, -- vocabulary value + target TEXT NOT NULL, -- the labeled reference + published INTEGER NOT NULL DEFAULT 0, -- Phase-4 exporter state (default off) + superseded_by TEXT -- id of the label that replaces this one +); +CREATE INDEX idx_mod_labels_case ON mod_labels(case_id); + +-- ---- Admin action audit (every phase writes this) ---- +-- INSERT-ONLY. What an admin DID: staging publishes, case state changes, label +-- applications. detail carries static reason tokens only — never content bodies, +-- never session material. (Roster changes are audited in wcjbt-auth's +-- admin_audit, next to the roster itself.) +CREATE TABLE admin_action_audit ( + id TEXT PRIMARY KEY, -- opaque random id + at INTEGER NOT NULL, -- epoch ms + actor TEXT NOT NULL, -- acting admin, 'provider:subject' + action TEXT NOT NULL, -- 'staging.create' | 'staging.publish' | 'case.open' | 'case.assign' | 'case.close' | 'label.apply' | 'label.supersede' + target TEXT NOT NULL, -- the acted-on id/ref + detail TEXT -- static reason token, optional +); +CREATE INDEX idx_admin_action_audit_at ON admin_action_audit(at);