mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Adds authenticated, role-based moderation to the relay admin API. On
`main` the admin API is read-only and gated only by `Host`/`Origin`
matching; this branch adds NIP-98 authentication, a two-tier
Operator/Moderator principal model, mutation and staffing endpoints, and
NIP-11 auto-discovery so clients never type the admin URL by hand.
## Authentication (`BUZZ_ADMIN_AUTH`)
`BUZZ_ADMIN_AUTH` accepts `nip98` or `disabled`. Leaving it unset
defaults to `nip98` (fail-secure). Configuration fails closed: any other
value aborts startup, while a lingering `BUZZ_ADMIN_TOKEN` is ignored
with a startup warning — token (bearer) authentication is not supported.
`Host`/`Origin` matching is retained in every mode as defense-in-depth.
- **`nip98`** (default) — per-request signed NIP-98 (kind 27235) events,
resolved to an Operator or Moderator principal with per-person
attribution and individual revocability. Read-write per resolved
principal.
- **`disabled`** — no credential; relies entirely on network-layer
controls (reverse proxy, VPN, firewall) and logs a `WARN` on every boot.
Always read-only: `authorize()` resolves no principal, so mutation and
staffing routes always `403`.
## Roles
Buzz has two independent authority axes after this change.
**Relay-level** roles (new here) are deployment-global: they act across
every community on the relay, through the admin API. **Community-level**
roles (pre-existing, unchanged by this PR) are tenant-scoped: they act
inside one community, through signed Nostr moderation commands.
### Relay level (new)
| Role | Description |
|---|---|
| **Operator** | Full control of the deployment's moderation surface:
read all reports, feedback, and attachments across every community;
resolve reports with enforcement (`delete`/`kick`/`ban`/`timeout`) or
decisions (`dismiss`/`escalate`); reopen and cancel; update feedback
status; and manage the Operator/Moderator roster via the staffing
endpoints. |
| **Moderator** | Day-to-day triage: everything an Operator can do
except staffing — cannot view or change the roster. |
How a pubkey acquires a relay role (resolution order; config always
outranks DB):
1. Listed in `RELAY_OPERATOR_PUBKEYS` → **Operator** (source `config`)
2. Equals `RELAY_OWNER_PUBKEY` while `RELAY_OPERATOR_PUBKEYS` is empty →
**Operator** (source `owner_fallback`, a break-glass grant for
self-hosters that deactivates once any operator is configured)
3. Row in the `relay_operators` table → **Operator** or **Moderator**
(source `db`, managed via the staffing endpoints)
4. No match → `403`
### Community level (pre-existing, unchanged)
| Role | Description |
|---|---|
| **Owner** (community) | Full authority within their community: every
moderation action (delete, kick, ban/unban, timeout/untimeout, resolve
reports, view queue) plus member, role, and invite management. No guard
rails. |
| **Admin** (community) | Same community-wide moderation capabilities as
owner, except an admin cannot ban or time out the owner or a fellow
admin — only the owner may action an admin. Manages members and invites;
only the owner grants the admin role. |
| **Member** (community) | Standard participant; no moderation
capability. |
| **Owner / Admin** (channel) | Channel-local authority only: delete
messages and kick users within their own channel. |
| **Member / Guest / Bot** (channel) | No moderation authority. |
There is no community-level Moderator tier in v1; relay-level Moderator
is the only role by that name.
## Escalation scoping
The operator report queue is an escalation backstop, not the community's
day-to-day triage surface (per `VISION_MODERATION`, the severe class is
the platform's to review rather than the community's). Two rules enforce
that:
- **Escalated-by-default listing.** `GET /reports` with no `status`
parameter returns only `escalated` reports. An explicit
`status=<open|resolved|dismissed|escalated>` filter is always honored as
given, and full visibility across every status stays available for
platform-safety and legal review via `scope=all` (which lists reports
regardless of status). `scope` accepts only `all` and is ignored when an
explicit `status` is present.
- **Auto-escalated `illegal` reports.** Member reports whose category is
`illegal` are ingested with `status=escalated` rather than `open`, so
the severe class reaches the operator backstop without waiting for a
community admin to forward it. Every other category still lands `open`.
Auto-escalation only sets the queue status — it records no moderator
decision and stamps no resolver, so an auto-escalated report is
indistinguishable downstream from an admin-escalated one: the reopen
route returns it to `open` on the same terms, keyed only on status,
never on how the report became escalated.
## Principal resolution and NIP-98 admission
`resolve_admin_principal()` returns `AdminPrincipal { pubkey, role,
source }` per the resolution order above; `None` never falls through as
a role. Admission is ordered so the replay guard is a privilege, not a
public surface: signature/URL/method/payload-hash verification first,
roster check second, and only then is the deployment-scoped replay id
atomically consumed — a validly-signing but unrostered key never
allocates a replay slot. Redis failure fails closed.
## Report resolution, recovery, and enforcement provenance
`POST /reports/{id}/resolve` is a crash-safe enforcement state machine:
decision-only outcomes (`dismiss`/`escalate`) are a single
CAS-plus-audit transaction; enforcement
(`delete`/`kick`/`ban`/`timeout`) claims the report
(`open`→`processing`), runs the durable mutation, then finalizes — a
re-drive resumes at the step marker and converges to exactly-one
enforcement, fenced by a lease and an outbox claim token.
Person-directed enforcement on an `event`-kind report derives its target
from the stored event's author (server-owned truth, never the reporter's
`p` tag) via a single `derive_enforcement_target` shared by the HTTP
driver and the recovery worker. If the reported event was purged before
its author could be read, person-directed actions are rejected pre-claim
and the report stays `open`; `delete` needs only the event id and is
exempt.
`GET /reports/{id}` and the resolve response carry an `activeAction`
field surfacing the enforcement that actually executed — a report
dismissed after a reopen still reports the ban that ran. `POST
/reports/{id}/reopen` returns a terminal report to `open` (idempotent on
`requestId`). `POST /reports/{id}/cancel` is the sole recovery path for
a pre-mutation `failed` action, attributed via
`relay_admin_actions.cancelled_by`.
## Feedback
`GET /feedback` and `/feedback/{id}` survive a tenant purge: provenance
columns are severed to `NULL` rather than cascade-deleted, and the
attachment path fails closed to `404` on a severed row. `PATCH
/feedback/{id}` updates lifecycle `status`
(`new`/`reviewed`/`archived`).
## Staffing and probe
`GET/PUT/DELETE /operators/{pubkey}` are Operator-only; mutating a
config-backed pubkey returns `409 Conflict`. `GET /operators` returns
the union of config and DB principals with per-entry `source`. `GET
/probe` reports auth mode, role, source, `canAct`, and `canStaff` for
the desktop console.
## NIP-11 auto-discovery
The NIP-11 relay-information document gains an optional `admin_api`
field carrying the canonical admin origin (`scheme://host[:port]`, no
path), present iff `BUZZ_ADMIN_HOST` is set and omitted otherwise. The
scheme follows the same loopback rule as NIP-98 `u`-tag verification via
a shared `scheme_for_host` helper, so the advertised origin and the
origin the relay verifies against can never diverge.
## Operator API origin decoupling
`RELAY_OPERATOR_API_ORIGIN` is no longer required at boot when
`RELAY_OPERATOR_PUBKEYS` is set — it is used only by the
community-provisioning endpoints, which fail closed at request time
(with a boot-time `WARN`) until it is set. The admin console needs no
origin.
## Admin-web adaptation
The standalone `admin-web` dashboard signs each request as a NIP-98
event via a NIP-07 browser extension, discovers the auth mode with a
single unauthenticated probe (`200` → `disabled`, anything else →
`nip98`, fail-secure), and carries no token entry surface. Playwright
coverage exercises the NIP-98 and CSP paths.
## Security hardening
Three findings from security review are folded in:
- **Append-only roster audit.** `PUT`/`DELETE /operators/{pubkey}`
mutate the deployment-wide root of trust, but the upsert overwrites
`role`/`added_by` in place and the delete removes the only row — so a
grant→revoke sequence left no trace of who was ever granted or by whom.
Each mutation now writes an `relay_operator_audit` row (actor, target,
`grant`/`revoke`, pre-image `prev_role`, `new_role`, timestamp) inside
the same transaction as the mutation. A per-target transaction-scoped
advisory lock serializes concurrent mutations of the same pubkey before
the pre-image read, so the recorded `prev_role` is always the true
predecessor even under a concurrent-grant race. Chronology is keyed on a
`BIGINT GENERATED ALWAYS AS IDENTITY` `seq` column, not the wall clock:
the serializing lock guarantees insertion order and `seq` captures it,
so ordered reads (`ORDER BY seq`) follow the true privilege chain even
across a backward NTP step that a `clock_timestamp()` ordering would
invert. `created_at` (`clock_timestamp()`) is retained as informational
occurrence time only. Append-only by construction — no `UPDATE`/`DELETE`
path and no API surface. A no-op delete writes nothing.
- **`expirationSecs` overflow.** The timeout path built `Utc::now() +
Duration::seconds(secs as i64)` from an attacker-controlled `u64`:
`i64::MAX` panicked the handler, and a wrapped-negative magnitude minted
a *past* expiry that still passed validation. `compute_timeout_until`
now rejects zero, rejects magnitudes above a documented
`MAX_TIMEOUT_SECS` (365 days), and uses checked
`try_seconds`/`checked_add_signed` so no input can panic or produce a
past expiry — over-cap, zero, `i64::MAX`, and wrapping-negative inputs
all return a clean `4xx`.
- **Uppercase-hex config-backed bypass.** Config pubkeys are lowercased
at parse, but the `409` immutability check raw-string-compared the path
param while `decode_hex_pubkey` accepted uppercase — so `PUT
/operators/{UPPERCASE}` skipped the guard and wrote a shadow row for the
same 32 bytes. The validated param is now canonicalized (lowercased)
before the `409` check, DB write, `DELETE`, and response body.
## Migrations
- `0035_relay_operators.sql` — `relay_operators` roster table
(deployment-global), `actor_authority` on `moderation_actions`,
`processing` status plus `active_action_id` on `moderation_reports`,
`status` on `product_feedback`.
- `0036_relay_admin_actions.sql` — enforcement-action table with a
`request_id` idempotency key, a `step_marker` for crash recovery, and a
`cancelled_by` attribution column.
- `0037_relay_admin_action_lease.sql` — lease fencing for the action
worker.
- `0038_relay_admin_outbox_claim_token.sql` — fenced claim token on the
outbox worker.
- `0039_relay_operator_audit.sql` — append-only `relay_operator_audit`
trail for roster mutations (see Security hardening).
`docs/admin/README.md` documents the full principal model, NIP-98 event
requirements, capabilities by role, the startup error matrix, and the
discovery field.
## Production blast radius
A relay without `BUZZ_ADMIN_HOST` is completely unaffected — the admin
surface stays disabled and `BUZZ_ADMIN_AUTH` is ignored; a lingering
`BUZZ_ADMIN_TOKEN` logs a startup warning and must be removed. Where
`BUZZ_ADMIN_HOST` **is** set, unset `BUZZ_ADMIN_AUTH` defaults to
`nip98` (per-person signed auth); `BUZZ_ADMIN_AUTH=disabled` reproduces
`main`'s prior `Host`/`Origin`-only gating but is read-only (mutation
routes `403`). The five migrations add tables and columns without
touching existing data.
---
Related: [block/buzz#4768](https://github.com/block/buzz/pull/4768)
(desktop admin console consuming the `admin_api` field),
[squareup/bb-public#339](https://github.com/squareup/bb-public/pull/339)
(Phase 4 rollout config)
---------
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
522 lines
17 KiB
TypeScript
522 lines
17 KiB
TypeScript
import { expect, type Page, test } from "@playwright/test";
|
|
|
|
interface ObjectUrlLog {
|
|
created: string[];
|
|
revoked: string[];
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
objectUrlLog: ObjectUrlLog;
|
|
}
|
|
}
|
|
|
|
/// Injects a minimal window.nostr stub that returns a fake signed event, so a
|
|
/// test can drive nip98 mode without a real NIP-07 extension. The stub derives
|
|
/// the event `id` from the signed fields (tags + created_at + content), so two
|
|
/// signings collide iff their signed payloads are byte-identical — exactly the
|
|
/// property the relay's replay guard keys on.
|
|
async function seedNip98(page: Page) {
|
|
await page.addInitScript(() => {
|
|
(window as Window & { nostr?: unknown }).nostr = {
|
|
signEvent: async (event: {
|
|
kind: number;
|
|
created_at: number;
|
|
tags: string[][];
|
|
content: string;
|
|
}) => {
|
|
const serialized = JSON.stringify([
|
|
event.kind,
|
|
event.created_at,
|
|
event.tags,
|
|
event.content,
|
|
]);
|
|
// Cheap non-crypto digest of the signed fields, hex-padded to 64 chars.
|
|
let h = 0;
|
|
for (let i = 0; i < serialized.length; i++) {
|
|
h = (Math.imul(31, h) + serialized.charCodeAt(i)) | 0;
|
|
}
|
|
const id = (h >>> 0).toString(16).padStart(8, "0").repeat(8);
|
|
return {
|
|
...event,
|
|
id,
|
|
pubkey: "b".repeat(64),
|
|
sig: "c".repeat(128),
|
|
};
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
/// Decode an `Authorization: Nostr <base64>` header to the signed event.
|
|
function decodeNostrHeader(header: string): { id: string; tags: string[][] } {
|
|
return JSON.parse(atob(header.replace(/^Nostr /, "")));
|
|
}
|
|
|
|
test("nip98 mode: attachments are fetched with a signed credential and rendered from blob urls", async ({
|
|
page,
|
|
}) => {
|
|
const id = "feedback-with-attachments";
|
|
const imageHash = "a".repeat(64);
|
|
const fileHash = "b".repeat(64);
|
|
const imageUrl = `https://design.buzz.xyz/media/${imageHash}.png`;
|
|
const fileUrl = `https://design.buzz.xyz/media/${fileHash}.txt`;
|
|
await seedNip98(page);
|
|
|
|
const attachmentRequests: { path: string; authorization?: string }[] = [];
|
|
await page.route(`**/api/admin/v1/feedback/${id}/attachments/**`, (route) => {
|
|
attachmentRequests.push({
|
|
path: new URL(route.request().url()).pathname,
|
|
authorization: route.request().headers().authorization,
|
|
});
|
|
route.fulfill({ contentType: "application/octet-stream", body: "bytes" });
|
|
});
|
|
await page.route(`**/api/admin/v1/feedback/${id}`, (route) =>
|
|
route.fulfill({
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
id,
|
|
communityId: "one",
|
|
communityHost: "design.buzz.xyz",
|
|
eventId: "31".repeat(32),
|
|
submitterPubkey: "21".repeat(32),
|
|
category: "bug",
|
|
body: "Composer froze.",
|
|
tags: [
|
|
[
|
|
"imeta",
|
|
`url ${imageUrl}`,
|
|
"m image/png",
|
|
`x ${imageHash}`,
|
|
"filename screenshot.png",
|
|
],
|
|
[
|
|
"imeta",
|
|
`url ${fileUrl}`,
|
|
"m text/plain",
|
|
`x ${fileHash}`,
|
|
"filename diagnostics.txt",
|
|
],
|
|
],
|
|
eventCreatedAt: "2026-07-17T17:25:00Z",
|
|
receivedAt: "2026-07-17T17:30:00Z",
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await page.goto(`/feedback/${id}`);
|
|
await expect(
|
|
page.getByRole("img", { name: "screenshot.png" }),
|
|
).toHaveAttribute("src", /^blob:/);
|
|
await expect(
|
|
page.getByRole("link", { name: /diagnostics.txt/ }),
|
|
).toHaveAttribute("href", /^blob:/);
|
|
|
|
expect(attachmentRequests.map((request) => request.path).sort()).toEqual(
|
|
[
|
|
`/api/admin/v1/feedback/${id}/attachments/${imageHash}`,
|
|
`/api/admin/v1/feedback/${id}/attachments/${fileHash}`,
|
|
].sort(),
|
|
);
|
|
for (const request of attachmentRequests) {
|
|
expect(request.authorization).toMatch(/^Nostr /);
|
|
}
|
|
});
|
|
|
|
/// Records every object URL the SPA creates and revokes, so a test can prove a
|
|
/// blob handed to the DOM is released rather than merely replaced.
|
|
async function instrumentObjectUrls(page: Page) {
|
|
await page.addInitScript(() => {
|
|
const log: ObjectUrlLog = { created: [], revoked: [] };
|
|
window.objectUrlLog = log;
|
|
const create = URL.createObjectURL.bind(URL);
|
|
const revoke = URL.revokeObjectURL.bind(URL);
|
|
URL.createObjectURL = (source: Blob | MediaSource) => {
|
|
const url = create(source);
|
|
log.created.push(url);
|
|
return url;
|
|
};
|
|
URL.revokeObjectURL = (url: string) => {
|
|
log.revoked.push(url);
|
|
revoke(url);
|
|
};
|
|
});
|
|
}
|
|
|
|
const FEEDBACK_ID = "feedback-with-attachments";
|
|
const IMAGE_HASH = "a".repeat(64);
|
|
const FILE_HASH = "b".repeat(64);
|
|
|
|
/// A feedback detail carrying one image and one non-image attachment. The
|
|
/// probe to `/reports` returns 200 so the SPA runs in disabled mode: these
|
|
/// tests exercise object-URL lifecycle, not authentication.
|
|
async function routeFeedbackDetail(page: Page) {
|
|
const host = "design.buzz.xyz";
|
|
await page.route(`**/api/admin/v1/reports**`, (route) =>
|
|
route.fulfill({ contentType: "application/json", body: "[]" }),
|
|
);
|
|
await page.route(`**/api/admin/v1/feedback?**`, (route) =>
|
|
route.fulfill({ contentType: "application/json", body: "[]" }),
|
|
);
|
|
await page.route(`**/api/admin/v1/feedback/${FEEDBACK_ID}`, (route) =>
|
|
route.fulfill({
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
id: FEEDBACK_ID,
|
|
communityId: "one",
|
|
communityHost: host,
|
|
eventId: "31".repeat(32),
|
|
submitterPubkey: "21".repeat(32),
|
|
category: "bug",
|
|
body: "Composer froze.",
|
|
tags: [
|
|
[
|
|
"imeta",
|
|
`url https://${host}/media/${IMAGE_HASH}.png`,
|
|
"m image/png",
|
|
`x ${IMAGE_HASH}`,
|
|
"filename screenshot.png",
|
|
],
|
|
[
|
|
"imeta",
|
|
`url https://${host}/media/${FILE_HASH}.txt`,
|
|
"m text/plain",
|
|
`x ${FILE_HASH}`,
|
|
"filename diagnostics.txt",
|
|
],
|
|
],
|
|
eventCreatedAt: "2026-07-17T17:25:00Z",
|
|
receivedAt: "2026-07-17T17:30:00Z",
|
|
}),
|
|
}),
|
|
);
|
|
}
|
|
|
|
test("attachment object urls are revoked when the view is left", async ({
|
|
page,
|
|
}) => {
|
|
await instrumentObjectUrls(page);
|
|
await routeFeedbackDetail(page);
|
|
await page.route(
|
|
`**/api/admin/v1/feedback/${FEEDBACK_ID}/attachments/**`,
|
|
(route) =>
|
|
route.fulfill({ contentType: "application/octet-stream", body: "bytes" }),
|
|
);
|
|
|
|
await page.goto(`/feedback/${FEEDBACK_ID}`);
|
|
const imageUrl = await page
|
|
.getByRole("img", { name: "screenshot.png" })
|
|
.getAttribute("src");
|
|
const fileUrl = await page
|
|
.getByRole("link", { name: /diagnostics.txt/ })
|
|
.getAttribute("href");
|
|
expect(imageUrl).toMatch(/^blob:/);
|
|
expect(fileUrl).toMatch(/^blob:/);
|
|
expect(await page.evaluate(() => window.objectUrlLog.revoked)).toEqual([]);
|
|
|
|
await page.getByRole("link", { name: "Back to feedback" }).click();
|
|
await expect(page.getByRole("heading", { name: "Feedback" })).toBeVisible();
|
|
|
|
await expect
|
|
.poll(() => page.evaluate(() => window.objectUrlLog.revoked))
|
|
.toEqual(expect.arrayContaining([imageUrl, fileUrl]));
|
|
});
|
|
|
|
test("an attachment that arrives after the view is left is revoked immediately", async ({
|
|
page,
|
|
}) => {
|
|
await instrumentObjectUrls(page);
|
|
await routeFeedbackDetail(page);
|
|
let release = () => {};
|
|
const held = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await page.route(
|
|
`**/api/admin/v1/feedback/${FEEDBACK_ID}/attachments/**`,
|
|
async (route) => {
|
|
await held;
|
|
await route.fulfill({
|
|
contentType: "application/octet-stream",
|
|
body: "bytes",
|
|
});
|
|
},
|
|
);
|
|
|
|
await page.goto(`/feedback/${FEEDBACK_ID}`);
|
|
// Both fetches are held, so no blob exists yet.
|
|
await expect(page.getByText("Loading…")).toBeVisible();
|
|
expect(await page.evaluate(() => window.objectUrlLog.created)).toEqual([]);
|
|
|
|
// Leave before either fetch resolves, then let both complete.
|
|
await page.getByRole("link", { name: "Back to feedback" }).click();
|
|
await expect(page.getByRole("heading", { name: "Feedback" })).toBeVisible();
|
|
release();
|
|
|
|
await expect
|
|
.poll(() => page.evaluate(() => window.objectUrlLog.revoked.length))
|
|
.toBe(2);
|
|
const log = await page.evaluate(() => window.objectUrlLog);
|
|
expect(log.revoked.sort()).toEqual(log.created.sort());
|
|
// Nothing was ever handed to the DOM: the blobs outlived their view.
|
|
await expect(page.getByRole("img", { name: "screenshot.png" })).toHaveCount(
|
|
0,
|
|
);
|
|
});
|
|
|
|
test("probe: disabled mode renders directly when the probe returns 200", async ({
|
|
page,
|
|
}) => {
|
|
// The probe to /api/admin/v1/reports returns 200, indicating the relay runs
|
|
// in disabled mode. The dashboard must render directly with no credential.
|
|
await page.route("**/api/admin/v1/reports**", (route) =>
|
|
route.fulfill({ contentType: "application/json", body: "[]" }),
|
|
);
|
|
|
|
await page.goto("/reports");
|
|
|
|
await expect(
|
|
page.getByRole("heading", { name: "Open reports" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("heading", { name: "Nostr extension required" }),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("probe: nip98 mode without a NIP-07 extension shows the installation screen", async ({
|
|
page,
|
|
}) => {
|
|
// The probe returns 401 and window.nostr is NOT injected, so the dashboard
|
|
// must show the extension installation screen instead of the dashboard.
|
|
await page.route("**/api/admin/v1/**", (route) =>
|
|
route.fulfill({
|
|
status: 401,
|
|
headers: { "www-authenticate": "Nostr" },
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
error: { code: "unauthorized", message: "nip98 required" },
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await page.goto("/reports");
|
|
|
|
await expect(
|
|
page.getByRole("heading", { name: "Nostr extension required" }),
|
|
).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "Open reports" })).toHaveCount(
|
|
0,
|
|
);
|
|
});
|
|
|
|
test("probe: nip98 mode with a mocked NIP-07 extension signs requests and renders the dashboard", async ({
|
|
page,
|
|
}) => {
|
|
await seedNip98(page);
|
|
|
|
const authorizationHeaders: (string | undefined)[] = [];
|
|
await page.route("**/api/admin/v1/**", async (route) => {
|
|
const headers = route.request().headers();
|
|
authorizationHeaders.push(headers.authorization);
|
|
// Probe: return 401 to trigger nip98 mode detection.
|
|
if (!headers.authorization) {
|
|
await route.fulfill({
|
|
status: 401,
|
|
headers: { "www-authenticate": "Nostr" },
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
error: { code: "unauthorized", message: "nip98 required" },
|
|
}),
|
|
});
|
|
} else {
|
|
// Any Authorization: Nostr header → accept.
|
|
await route.fulfill({ contentType: "application/json", body: "[]" });
|
|
}
|
|
});
|
|
|
|
await page.goto("/reports");
|
|
|
|
await expect(
|
|
page.getByRole("heading", { name: "Open reports" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("heading", { name: "Nostr extension required" }),
|
|
).toHaveCount(0);
|
|
// The authenticated request used Authorization: Nostr.
|
|
const authenticatedHeaders = authorizationHeaders.filter(Boolean);
|
|
expect(authenticatedHeaders.length).toBeGreaterThan(0);
|
|
for (const h of authenticatedHeaders) {
|
|
expect(h).toMatch(/^Nostr /);
|
|
}
|
|
});
|
|
|
|
test("nip98 mode: same-second retry re-signs with a distinct event id", async ({
|
|
page,
|
|
}) => {
|
|
// Freeze the clock so both signings share created_at (1s resolution). With
|
|
// only u+method+created_at signed, the two events would be byte-identical
|
|
// and collide in the relay's replay guard, so the 401 retry could never
|
|
// recover. The per-signing random nonce tag must make the second event's id
|
|
// distinct despite the frozen clock.
|
|
await page.addInitScript(() => {
|
|
const FROZEN = 1_760_000_000_000;
|
|
const RealDate = Date;
|
|
// biome-ignore lint/suspicious/noExplicitAny: minimal Date shim for the test
|
|
(globalThis as any).Date = class extends RealDate {
|
|
constructor(...args: unknown[]) {
|
|
// biome-ignore lint/suspicious/noExplicitAny: forward constructor args
|
|
super(...(args.length ? (args as any) : [FROZEN]));
|
|
}
|
|
static now() {
|
|
return FROZEN;
|
|
}
|
|
};
|
|
});
|
|
await seedNip98(page);
|
|
|
|
const authCalls: string[] = [];
|
|
let signCount = 0;
|
|
await page.route("**/api/admin/v1/**", async (route) => {
|
|
const headers = route.request().headers();
|
|
if (!headers.authorization) {
|
|
await route.fulfill({
|
|
status: 401,
|
|
headers: { "www-authenticate": "Nostr" },
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
error: { code: "unauthorized", message: "nip98 required" },
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
authCalls.push(headers.authorization);
|
|
signCount++;
|
|
// First authenticated attempt → reject, forcing the re-sign + retry.
|
|
await route.fulfill(
|
|
signCount === 1
|
|
? {
|
|
status: 401,
|
|
headers: { "www-authenticate": "Nostr" },
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
error: { code: "unauthorized", message: "rejected" },
|
|
}),
|
|
}
|
|
: { contentType: "application/json", body: "[]" },
|
|
);
|
|
});
|
|
|
|
await page.goto("/reports");
|
|
await expect(
|
|
page.getByRole("heading", { name: "Open reports" }),
|
|
).toBeVisible();
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
expect(authCalls).toHaveLength(2);
|
|
const [first, second] = authCalls.map(decodeNostrHeader);
|
|
// Same frozen created_at, yet distinct ids — the nonce tag did its job.
|
|
expect(first.id).not.toBe(second.id);
|
|
const nonceOf = (tags: string[][]) => tags.find((t) => t[0] === "nonce")?.[1];
|
|
expect(nonceOf(first.tags)).toBeTruthy();
|
|
expect(nonceOf(second.tags)).toBeTruthy();
|
|
expect(nonceOf(first.tags)).not.toBe(nonceOf(second.tags));
|
|
});
|
|
|
|
test("nip98 mode: first-401-then-200 retries once and renders the dashboard", async ({
|
|
page,
|
|
}) => {
|
|
// Models a credential that is momentarily rejected (clock skew, key
|
|
// rotation) then accepted on the second attempt.
|
|
let signCount = 0;
|
|
await seedNip98(page);
|
|
|
|
const authCalls: string[] = [];
|
|
await page.route("**/api/admin/v1/**", async (route) => {
|
|
const headers = route.request().headers();
|
|
if (!headers.authorization) {
|
|
// Probe — announce nip98 mode.
|
|
await route.fulfill({
|
|
status: 401,
|
|
headers: { "www-authenticate": "Nostr" },
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
error: { code: "unauthorized", message: "nip98 required" },
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
authCalls.push(headers.authorization);
|
|
signCount++;
|
|
if (signCount === 1) {
|
|
// First authenticated attempt → reject.
|
|
await route.fulfill({
|
|
status: 401,
|
|
headers: { "www-authenticate": "Nostr" },
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
error: { code: "unauthorized", message: "rejected" },
|
|
}),
|
|
});
|
|
} else {
|
|
// Second attempt → accept.
|
|
await route.fulfill({ contentType: "application/json", body: "[]" });
|
|
}
|
|
});
|
|
|
|
await page.goto("/reports");
|
|
|
|
// The retry should succeed. Wait for network to settle (both attempts
|
|
// complete) before asserting the authCalls count.
|
|
await page.waitForLoadState("networkidle");
|
|
// Exactly two distinct Nostr credentials were sent (one per attempt).
|
|
expect(authCalls).toHaveLength(2);
|
|
for (const h of authCalls) {
|
|
expect(h).toMatch(/^Nostr /);
|
|
}
|
|
});
|
|
|
|
test("nip98 mode: persistent 401 surfaces error after exactly one retry", async ({
|
|
page,
|
|
}) => {
|
|
// Every authenticated request returns 401. The SPA must attempt exactly
|
|
// two requests (first attempt + one retry) and then surface the error —
|
|
// never a third attempt.
|
|
await seedNip98(page);
|
|
|
|
const authCalls: string[] = [];
|
|
await page.route("**/api/admin/v1/**", async (route) => {
|
|
const headers = route.request().headers();
|
|
if (!headers.authorization) {
|
|
await route.fulfill({
|
|
status: 401,
|
|
headers: { "www-authenticate": "Nostr" },
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
error: { code: "unauthorized", message: "nip98 required" },
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
authCalls.push(headers.authorization);
|
|
await route.fulfill({
|
|
status: 401,
|
|
headers: { "www-authenticate": "Nostr" },
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
error: { code: "unauthorized", message: "rejected" },
|
|
}),
|
|
});
|
|
});
|
|
|
|
await page.goto("/reports");
|
|
|
|
// After the retry fails, the error state renders. The StateView shows
|
|
// "Could not load data" inside a role=alert region.
|
|
await expect(page.getByRole("alert")).toBeVisible();
|
|
await expect(
|
|
page.getByRole("heading", { name: "Could not load data" }),
|
|
).toBeVisible();
|
|
// Exactly two Nostr credentials sent — no third attempt.
|
|
await page.waitForLoadState("networkidle");
|
|
expect(authCalls).toHaveLength(2);
|
|
});
|