From 1083c62ec5fb965d55539a8e9e654f23f0e2775d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 22:41:28 +0000 Subject: [PATCH 1/2] feat(admin): surface resolved role in the auth responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit login/verify, elevate, and whoami (both paths) now include the per-request resolved role ('superadmin' | 'admin') alongside identity + method, so the console can route straight to the right panel after approval without a second round-trip. Role is still NEVER persisted in the session record — it is re-derived per request (resolveRoledAdmin), so revocation stays effective on the next request; it rides the response for rendering only. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U8EGtxYhdvUcvhCZLBrXFc --- worker/admin/router.ts | 27 ++++++++++++++++++--------- worker/tests/admin-auth.test.ts | 11 +++++++---- worker/tests/admin-roster.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/worker/admin/router.ts b/worker/admin/router.ts index 4b76cc1..7cdaf39 100644 --- a/worker/admin/router.ts +++ b/worker/admin/router.ts @@ -143,9 +143,11 @@ async function nostrVerify(request: Request, env: AdminEnv, deps: AdminDeps): Pr ); if (!proven) return authError(); // Membership (file ∪ roster) AFTER proof, same generic 401 as a failed proof - // (no oracle). - if (!(await resolveAdminRole('nostr', proven.pubkey, env, deps))) return authError(); - return openAdminSession(env, 'nostr', proven.pubkey); + // (no oracle). The resolved role rides the login response so the console can + // route straight to the right panel without a second round-trip. + const role = await resolveAdminRole('nostr', proven.pubkey, env, deps); + if (!role) return authError(); + return openAdminSession(env, 'nostr', proven.pubkey, role); } async function elevateBluesky(request: Request, env: AdminEnv, deps: AdminDeps): Promise { @@ -164,8 +166,10 @@ async function elevateBluesky(request: Request, env: AdminEnv, deps: AdminDeps): } catch { return authError(); } - if (!did || !(await resolveAdminRole('bluesky', did, env, deps))) return authError(); - return openAdminSession(env, 'bluesky', did); + if (!did) return authError(); + const role = await resolveAdminRole('bluesky', did, env, deps); + if (!role) return authError(); + return openAdminSession(env, 'bluesky', did, role); } /** Shared tail of both login paths: identity is PROVEN + ALLOWLISTED. Gate on the @@ -175,15 +179,19 @@ async function openAdminSession( env: AdminEnv & { ADMIN_SESSIONS: KVNamespace; ADMIN_COORD: DurableObjectNamespace }, method: AdminMethod, subject: string, + role: AdminRole, ): Promise { const id = newAdminSessionId(); const opened = await coord(env.ADMIN_COORD, method, subject, '/open', { sessionId: id }); if (!opened || opened.status !== 200 || opened.data.ok !== true) { return opened?.status === 429 ? authError(429, 'too many requests') : authError(); } + // Role is NOT persisted in the session record — it is re-derived per request + // (resolveRoledAdmin) so a revocation takes effect next request. It rides the + // response purely so the console can render the right panel immediately. await putAdminSession(env.ADMIN_SESSIONS, id, subject, method); return authJson( - { identity: subject, method, csrf: opened.data.csrf }, + { identity: subject, method, role, csrf: opened.data.csrf }, 200, { 'set-cookie': adminSessionCookie(id) }, ); @@ -234,7 +242,7 @@ async function whoami(request: Request, env: AdminEnv, deps: AdminDeps): Promise const { record } = session; const csrfRes = await coord(env.ADMIN_COORD, record.method, record.subject, '/csrf', { sessionId: session.id }); const csrf = csrfRes?.status === 200 ? (csrfRes.data.csrf as string) : undefined; - return authJson({ identity: record.subject, method: record.method, ...(csrf ? { csrf } : {}) }); + return authJson({ identity: record.subject, method: record.method, role: session.role, ...(csrf ? { csrf } : {}) }); } // Path 2 — API client: per-request NIP-98 over this exact URL + method, with a @@ -244,9 +252,10 @@ async function whoami(request: Request, env: AdminEnv, deps: AdminDeps): Promise if (authHeader) { const proven = await verifyNip98Event(authHeader, '', adminUrl(request, env, '/api/admin/whoami'), 'GET'); if (!proven) return authError(); - if (!(await resolveAdminRole('nostr', proven.pubkey, env, deps))) return authError(); + const role = await resolveAdminRole('nostr', proven.pubkey, env, deps); + if (!role) return authError(); if (!(await burnNip98EventId(env.ADMIN_SESSIONS, proven.eventId))) return authError(); - return authJson({ identity: proven.pubkey, method: 'nostr' }); + return authJson({ identity: proven.pubkey, method: 'nostr', role }); } return authError(); diff --git a/worker/tests/admin-auth.test.ts b/worker/tests/admin-auth.test.ts index 73ca9a9..cc0fcf1 100644 --- a/worker/tests/admin-auth.test.ts +++ b/worker/tests/admin-auth.test.ts @@ -247,9 +247,10 @@ test('allowlisted NIP-98 login mints a Strict __Host- admin session + csrf', asy const deps = { allowlist: { nostr: [{ pubkey: pk, role: 'admin' }], bluesky: [] } }; const res = await nostrLogin(env, sk, deps); assert.equal(res.status, 200); - const body = (await res.json()) as { identity: string; method: string; csrf: string }; + const body = (await res.json()) as { identity: string; method: string; role: string; csrf: string }; assert.equal(body.identity, pk); assert.equal(body.method, 'nostr'); + assert.equal(body.role, 'admin'); // role rides the login response for panel routing assert.match(body.csrf, /^[0-9a-f]{64}$/); const cookie = cookieOf(res); assert.ok(cookie.startsWith(`${ADMIN_SESSION_COOKIE}=`)); @@ -261,9 +262,10 @@ test('allowlisted NIP-98 login mints a Strict __Host- admin session + csrf', asy // whoami rides the cookie and reports the SAME identity + csrf for the tab const who = await routeAdmin(withAdminCookie('/api/admin/whoami', sessionIdOf(res)), env, deps); assert.equal(who.status, 200); - const whoBody = (await who.json()) as { identity: string; method: string; csrf?: string }; + const whoBody = (await who.json()) as { identity: string; method: string; role: string; csrf?: string }; assert.equal(whoBody.identity, pk); assert.equal(whoBody.method, 'nostr'); + assert.equal(whoBody.role, 'admin'); assert.equal(whoBody.csrf, body.csrf); }); @@ -437,7 +439,7 @@ test('per-request NIP-98 whoami: allowlisted GET signature works once, replay di const call = () => routeAdmin(req('/api/admin/whoami', { headers: { authorization: token } }), env, deps); const first = await call(); assert.equal(first.status, 200); - assert.deepEqual(await first.json(), { identity: pk, method: 'nostr' }); + assert.deepEqual(await first.json(), { identity: pk, method: 'nostr', role: 'admin' }); assert.equal((await call()).status, 401); // single-use event id → replay rejected }); @@ -483,9 +485,10 @@ test('an allowlisted DID elevates its user session into an admin session', async method: 'POST', headers: { cookie: `${SESSION_COOKIE}=${sid}` }, }), env, deps); assert.equal(res.status, 200); - const body = (await res.json()) as { identity: string; method: string; csrf: string }; + const body = (await res.json()) as { identity: string; method: string; role: string; csrf: string }; assert.equal(body.identity, did); assert.equal(body.method, 'bluesky'); + assert.equal(body.role, 'admin'); assert.ok(cookieOf(res).includes('SameSite=Strict')); // whoami on the new admin session reports the DID const who = await routeAdmin(withAdminCookie('/api/admin/whoami', sessionIdOf(res)), env, deps); diff --git a/worker/tests/admin-roster.test.ts b/worker/tests/admin-roster.test.ts index 9cf5c37..102d9d7 100644 --- a/worker/tests/admin-roster.test.ts +++ b/worker/tests/admin-roster.test.ts @@ -461,6 +461,28 @@ test('I3 LOGOUT destroy-on-null: a de-listed principal\'s logout destroys the re assert.equal(await env.ADMIN_SESSIONS.get(`adm:${rosterSession.id}`), null, 'destroyed, not merely rejected'); }); +// ---- role rides the auth responses (drives console panel routing) ---- + +test('login + whoami report role: file superadmin ⇒ superadmin, roster member ⇒ admin', async () => { + const env = fakeEnv(); + const superSk = generateSecretKey(); + const deps = fileFixture(getPublicKey(superSk), 'e'.repeat(64)); + + const login = await nostrLogin(env, superSk, deps); + assert.equal(((await login.clone().json()) as { role: string }).role, 'superadmin'); + const superId = sessionIdOf(login); + const who = await routeAdmin(withAdminCookie('/api/admin/whoami', superId), env, deps); + assert.equal(((await who.json()) as { role: string }).role, 'superadmin'); + + // Seed a roster admin and confirm THEIR responses carry role admin, not superadmin. + const { csrf } = (await login.json()) as { csrf: string }; + const rosterSk = generateSecretKey(); + const rosterPk = getPublicKey(rosterSk); + await mutate('/api/admin/admins/add', { id: superId, csrf, sk: superSk, pk: getPublicKey(superSk) }, { provider: 'nostr', subject: rosterPk }, env, deps); + const rosterLogin = await nostrLogin(env, rosterSk, deps); + assert.equal(((await rosterLogin.json()) as { role: string }).role, 'admin'); +}); + // ---- management API surface ---- test('list returns file principals marked immutable ∪ roster entries with provenance', async () => { From 837769e3c551474d542a375f85cf02f806261d97 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 22:47:55 +0000 Subject: [PATCH 2/2] feat(console): route approved sign-in to a role-aware admin panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously /console/ ended at a 'Signed in' confirmation card with nowhere to go. Now, once the server approves a login, the console routes on the returned role: - superadmin → the admin-management panel: lists the effective admin set (file-governed principals shown read-only + runtime roster) and adds/removes RUNTIME admins via the live /api/admin/admins* API — each mutation carries the session CSRF token and is audit-logged server-side. npub/hex/DID input; friendly mapping of 400/403/404/409/429/503; auto-refresh after each change; a 401 anywhere drops back to sign-in. - admin → a signed-in home (no management tools for this role yet). Panel state is reset on logout so a next admin never sees the prior roster view. Stays a static /console/ asset (all calls same-origin), Svelte-only, no new deps; file principals remain immutable here (PR-governed). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U8EGtxYhdvUcvhCZLBrXFc --- src/components/AdminConsole.svelte | 277 +++++++++++++++++++++++++++-- 1 file changed, 263 insertions(+), 14 deletions(-) diff --git a/src/components/AdminConsole.svelte b/src/components/AdminConsole.svelte index 2695003..558d133 100644 --- a/src/components/AdminConsole.svelte +++ b/src/components/AdminConsole.svelte @@ -1,22 +1,45 @@ @@ -175,10 +304,99 @@

Signed in

Identity {shortId(who.identity)}

-

Method {who.method}

+

+ Method {who.method} + · Role {who.role} +

Sessions expire after 15 minutes idle and 8 hours absolute.

+ + {#if who.role === 'superadmin'} +
+

Admins

+

+ File-governed admins are set through repository PRs and are read-only here. + Runtime admins can be added or removed below — every change is audit-logged. +

+ + {#if panelBusy === 'load' && !admins} +

Loading the admin list…

+ {:else if admins} + {#if admins.length === 0} +

No admins found.

+ {:else} +
    + {#each admins as a (a.provider + ':' + a.subject)} +
  • +
    + {shortId(a.subject)} + {a.provider} + {a.role} + {#if a.source === 'file'} + file · read-only + {/if} +
    + {#if a.note}
    {a.note}
    {/if} + {#if a.source === 'roster'} + + {/if} +
  • + {/each} +
+ {/if} + + {/if} + +
+

Add a runtime admin

+
+ + + + +
+ + + +
+ + {#if panelError}{/if} +
+ {:else} +
+

+ You’re signed in as an admin. No admin tools are available to your role yet. +

+
+ {/if} {:else}

Nostr

@@ -219,17 +437,40 @@ .console { display: grid; gap: 1rem; max-width: 34rem; } .card { border: 1px solid #8884; border-radius: 0.5rem; padding: 1rem; display: grid; gap: 0.6rem; } .card h2 { margin: 0; font-size: 1.05rem; } + .card h3 { margin: 0.2rem 0 0; font-size: 0.95rem; } .btn { font: inherit; cursor: pointer; padding: 0.5rem 0.9rem; border-radius: 0.4rem; border: 1px solid #8886; background: transparent; color: inherit; min-height: 2.75rem; } .btn:disabled { opacity: 0.6; cursor: default; } + .small-btn { min-height: 2.25rem; padding: 0.35rem 0.7rem; font-size: 0.9rem; justify-self: start; } .row { display: flex; gap: 0.5rem; } .row input { flex: 1; min-width: 0; font: inherit; font-size: max(16px, 1rem); padding: 0.5rem 0.6rem; border-radius: 0.4rem; border: 1px solid #8886; background: transparent; color: inherit; } + .row select { + font: inherit; padding: 0.5rem 0.6rem; border-radius: 0.4rem; + border: 1px solid #8886; background: transparent; color: inherit; + } + .add { display: grid; gap: 0.5rem; border-top: 1px solid #8884; padding-top: 0.75rem; margin-top: 0.25rem; } + .add input { + font: inherit; font-size: max(16px, 1rem); padding: 0.5rem 0.6rem; + border-radius: 0.4rem; border: 1px solid #8886; background: transparent; color: inherit; + } + .admins { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.6rem; } + .admins li { + display: grid; gap: 0.3rem; padding: 0.5rem 0.6rem; + border: 1px solid #8883; border-radius: 0.4rem; + } + .who-line { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; } + .tag { + font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.02em; + padding: 0.1rem 0.4rem; border-radius: 0.3rem; border: 1px solid #8886; color: #aaa; + } + .tag-super { color: #e8e8ef; border-color: #aaa; } + .note { margin: 0; } .or { display: flex; align-items: center; gap: 0.5rem; color: #888; font-size: 0.85rem; } .or::before, .or::after { content: ""; flex: 1; height: 1px; background: #8884; } .muted { color: #888; } @@ -237,4 +478,12 @@ .error { color: #c0392b; margin: 0; } .notice { color: #888; margin: 0; } code { word-break: break-all; } + .sr-only { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; + } + @media (prefers-color-scheme: light) { + .tag { color: #666; } + .tag-super { color: #1a1a24; border-color: #666; } + }