diff --git a/src/components/AdminConsole.svelte b/src/components/AdminConsole.svelte index 26950039..558d133b 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} + + {/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; } + } diff --git a/worker/admin/router.ts b/worker/admin/router.ts index 4b76cc15..7cdaf39d 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 73ca9a9e..cc0fcf1c 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 9cf5c37d..102d9d74 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 () => {