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.
Sign out
+
+ {#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}
+
Refresh
+ {/if}
+
+
+
Add a runtime admin
+
+ Network
+
+ Nostr
+ Bluesky
+
+ Identifier
+
+
+
Note
+
+
+ {panelBusy === 'add' ? 'Adding…' : 'Add admin'}
+
+
+
+ {#if panelError}
{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 () => {