Zum Inhalt springen

10 — Admin Console (Sub-system Design)

Status: Draft Date: 2026-05-23 Owner: frontend + backend (CF Access) Parent: 2026-05-23-nowyourlink-master-design.md (binding — D15 CF Access for admin, D24 Terraform Access app, D26 launch posture, §4.1 admin-identity-not-in-users-table, §10.2 cost envelope) Consumes: 02 identity, 03 moderation, 04 auction-engine, 05 payment-saga, 06 real-time-hub. Owns no business logic — thin control surface.


  • apps/admin Hono worker at admin.nowyourlink.com, gated by Cloudflare Access. It renders the admin HTML server-side and consumes the design system via @nowyourlink/ui’s CVA variant contract + @nowyourlink/design-tokens — NOT the .astro primitives (master D42).
  • 14 routes (§3) backed by service-binding RPCs into 02/03/04/05/06 and D1 reads via the DB binding.
  • House-ad CMS — the only content owned natively by 10; backs the reserved users.id='house' author that 03 defines and 05’s pickHouseAd(auction_id) reads.
  • admin_audit D1 table; write path on every mutating route.
  • Feature-flag UI on top of KV CONFIG (binding from 01 §6).
  • Bid validation, ranking (04); payment/credit-note calls (05 owns Stripe); moderation auto-filter (03); session minting (02); WS broadcast (06). The admin console reads and signals; it does not implement.

Cloudflare Access fronts admin.nowyourlink.com via 01 §5 Terraform. Headers injected on every authenticated request:

  • Cf-Access-Authenticated-User-Email — verified email (display only; emails can change).
  • Cf-Access-Jwt-Assertion — signed JWT; sub is the stable identity. apps/admin/src/cf-access.ts verifies the JWT against https://${env.CF_ACCESS_TEAM}/cdn-cgi/access/certs with aud = env.CF_ACCESS_AUD (spec 01 §7 + the #122 rename callout); JWKS cached 1 h.

No admin user table (master §4.1). Audit records sub + email. Service-binding RPCs forward both headers; downstream workers re-verify against the same aud to prevent SSRF lateral movement from apps/api.

Authorization (#309 — single super-admin)

Section titled “Authorization (#309 — single super-admin)”

Authorization is the Cloudflare Access email allowlist (admin_access_allowed_emails in infra/terraform/access.tf). Any operator who clears that allowlist is the founder / super-admin and may use every route. apps/admin/src/cf-access.ts authenticates the signed JWT (sub + email + aud) and resolves a single constant role (founder), recorded only as the audit-trail actor tag.

Why there is no per-role RBAC at MVP. The original design resolved one of four roles from a groups claim in the JWT. That mechanism does not exist for self-hosted Cloudflare Access apps: Access rule groups (cloudflare_zero_trust_access_group) are a policy primitive and are never emitted in the app’s Cf-Access-Jwt-Assertion. The groups claim is IdP-sourced (SAML/OIDC), and the admin gate’s OTP-via-email IdP supplies none — so every JWT carried no group and the console returned 403 admin.role-unmapped for every operator, including the single founder. See closed issue #307 (root-cause) and #309 (this change). The lone operator signs in on a personal @googlemail.com with no group membership, so an IdP swap would not have helped either.

Reintroducing RBAC (the capability table below) requires a real IdP that emits group membership (Google Workspace / Okta / Entra / generic OIDC), at which point the worker maps IdP group names → roles. Until then the table is advisory, not enforced; the per-route “Roles” annotations in §3 describe that future model.

IdP group (future)RoleCapabilities (future)
nyl-foundersfounderEverything, incl. saga resume/cancel, feature flags
nyl-adminsadminEverything except feature-flag writes and saga resume/cancel
nyl-moderatorsmoderator/admin/queue, /admin/house-ads, read-only on /admin/audit
nyl-financefinanceRead-only on /admin/refunds, /admin/revenue, /admin/audit

RouteRolesPurpose
/adminallDashboard tiles: queue depth, sagas running, today’s Spotlight
/admin/queuemoderator+Moderation queue (consumes 03 §6)
/admin/auctionsadmin+30-day calendar live monitor (subscribes 06 lobby)
/admin/auctions/:dateadmin+Per-auction detail (ranked bid list, anti-snipe state)
/admin/sagasadmin+List Workflow instances (reads saga_runs 05 §2)
/admin/sagas/:instance_idadmin+ (resume/cancel = founder)Vertex history, retry counts, last error
/admin/biddersadmin+Search by email/VAT-ID/company
/admin/bidders/:user_idadmin+Bidder card + strike/ban/Identity-escalation actions
/admin/refundsadmin+ (POST), finance (GET)Credit-note issuance (sequence: processes/10-manual-refund.md)
/admin/house-adsmoderator+CMS for reserved users.id='house' ads
/admin/revenueadmin+, financeMRR, capture rate, top advertisers
/admin/analyticsadmin+, financeTraffic roll-ups (CF Web Analytics + pixel)
/admin/feature-flagsfounderKV CONFIG editor
/admin/auditall (read-only)Paginated admin_audit view

Renders 03 GET /admin/moderation/queue. Each row shows: thumb (R2 thumb.webp), bidder, headline/body, target URL, Llama-Guard verdict, LLaVA caption, blacklist hits, ResNet-50 top-1, claim state. One-click Approve / Reject with reason picker (nsfw-flag | keyword-block | url-unsafe | manual-reject | revoked-copyright | revoked-policy). Keyboard triage: J/K nav, A approve, R reject (modal), C claim, Esc release. POSTs to 03 /admin/moderation/:ad_id/decision. Conflicts surface as ad.state-conflict via 03 §E9 — no new locking primitive (see §13 E1). Sequence: processes/10-moderation-decision.md.


Calendar of the next 30 days. WS subscription to HubDO("lobby") for cross-cutting events + per-cell subscription to AuctionDO(date) on hover (06 §2). Per cell: bidder count, last bid amount (admin-only), velocity (bids/min), anti-snipe state (enabled?, current closes_at_actual, total_extension_ms).

Cell click → /admin/auctions/:date: ranked active bid list from 04 getRankedBids() with amounts and bidder identities. Security invariant: amount-disclosure is enforced server-side — 04’s getRankedBids() RPC requires a valid Access JWT whose aud apps/api re-verifies; at MVP there is no role predicate (#309 — every authenticated operator is the founder/super-admin), so the boundary is “holds a valid admin token,” tightening back to role ∈ {founder, admin} only when a real IdP reintroduces roles (§2). The bidder-facing 06 broadcast taxonomy never carries amounts (06 §5 — bid_placed_pulse has counters only). Admin UI is not the security boundary.

Anti-snipe toggle calls 04 setAntiSnipeConfig (existing RPC, 04 §6); audit-logged.


Lists Workflow instances from D1 mirror saga_runs (05 §2). Filter by status, auction_id, current_step. Detail renders the 15-vertex machine (05 §3) with per-step timing (structured logs §10), attempt counters, last_error, winner_user_id, invoice_id. Resume (POST /admin/sagas/:instance_id/resume → env.SAGA.get(id).restart()) — founder only; double-confirm. Cancel — founder; pre-condition status='running' and current_step ∉ {ACTIVATE, LIVE}. Both audit-logged.


Search by email, vat_id, company_name (D1 LIKE on 02 §2.1 indexes). Per-bidder card pulls from 02 (tier, strikes, banned_at, kyc_level, vat_validation_status, pm summary), 04 for lifetime bids/wins, 05 for lifetime revenue.

D5 exception (#575, founder-approved): the per-bidder win-rate reads the user_bids(date, user_id) D1 table (migration 0026) — persisted bidder participation identity, written at settlement from the server-only DO settlement snapshot. This lifts the master-D5 sealed-bid invariant for admin query only, under data-minimisation: identity-of-participation only — no amount, no ad id, no ranking. Forward-only (no backfill — pre-0026 days have no honest source; the UI captions the recorded window). Every read of the surface writes an admin_actions row (§12). Retention matches the other user_id-keyed ledgers (saga_runs, invoices, saga_strikes): rows persist as pseudonymous facts keyed on the opaque users.id; a data-rights erase anonymises the users row in place (spec 12), severing the id from any identity, so user_bids needs no erase handling of its own. Actions (all → 02’s /admin/users/:id/*, audit-logged): Force strike, Unban (clears banned_at, retains strike row), Force Stripe Identity (opens kyc_escalations), Manual VIES re-check (enqueues 02 §4 retry).


Search by auction_id or Stripe invoice_id. The detail panel calls invoices.retrieve via 05 to display the canonical PaymentIntent id verbatim — admin must re-type the auction_id in the confirm dialog. Full or partial credit note. Submission signals the existing AuctionSaga via instance.sendEvent({ type:'admin-refund', payload }); the saga walks 05’s REFUND vertex (05 §9). Audit row is written before the signal, updated on refund.completed. Finance role: GET only. Sequence: processes/10-manual-refund.md.


10 owns the table; 03 only knows the reserved users.id='house' author and skips auto-filter (03 §4.4); 05 calls pickHouseAd(auction_id).

CREATE TABLE house_ads (
id TEXT PRIMARY KEY,
ad_id TEXT NOT NULL REFERENCES ads(id), -- owned by 03 (state='approved')
available_from TEXT NOT NULL, -- ISO date inclusive
available_to TEXT NOT NULL, -- ISO date inclusive
weight INTEGER NOT NULL DEFAULT 1, -- equal weights rotate on house days; mixed weights draw
retired_at TEXT,
created_by TEXT NOT NULL, -- CF Access sub
created_at TEXT NOT NULL
);
CREATE INDEX ix_house_ads_window ON house_ads(available_from, available_to, retired_at);

pickHouseAd(auction_id) reads live, in-window rows with weight >= house-ad-min-weight in stable creation order. When all eligible weights match, the number of published house days in daily_spotlight (excluding the current date) chooses the next row in a cycle; an already published house row for the current date keeps its creative if still eligible. Differing weights keep a weighted draw. The saga only calls it on cascade-exhaustion or ACTIVATE_FAILED, so paid Spotlight days retain priority. The sequence advances on served house days when the pool stays stable; a paid day does not skip a house creative.

#340 transport ruling (as-built). Originally specced as “a service-binding RPC exposed by apps/admin” (and §15 listed apps/admin as an Astro worker with a SAGA binding). The as-built apps/admin is a Hono worker with no WorkerEntrypoint, and the payment saga (05, on apps/api) already holds the DB binding house_ads lives in. So pickHouseAd ships as a plain D1Database function in @nowyourlink/users/house-ads (10-owned selection logic; migration 0017_house_ads.sql), called directly by the saga via its DB binding — the same shape #338’s loadRankedBidders reads D1 users. apps/admin’s house-ad CMS (its PR-6) imports the same function. Routing a plain SELECT through a new cross-worker service binding buys nothing; #10 still owns the table + the selection module.

#340 house-identity invariant (resolves the spec 03-acceptance ⚠). Two house conventions coexisted: the moderation author identity users.id='house' (03 §4.4) and the public-site spotlight marker daily_spotlight.ad_id LIKE 'house:%' (07 §3 E1, which suppresses the tracked /go redirect so a house-ad day never pollutes advertiser click metrics). Authoritative rule: a house ad is an ads row authored by users.id='house'; house_ads.ad_id references that normal UUID ads.id (its generation in 03 is untouched — the house: prefix is not part of ads.id). The house: prefix is a SPOTLIGHT-LAYER marker: pickHouseAd returns the selected ad’s id with the prefix applied (house:<ads.id>), i.e. the exact value the 04 settlement writer persists to daily_spotlight.ad_id on the HOUSE_AD-cascade outcome, so the public site recognizes it and 404s /go (07 §3 E1). The encoding is reversible (stripHousePrefix recovers ads.id). Both consumers — moderation’s users.id='house' auto-filter skip (03 §4.4) and the web’s ad_id LIKE 'house:%' check (07 §3) — stay unchanged and no longer diverge. No match → pickHouseAd returns null and the saga raises the P0 (§14 acceptance item 10; 05 §3 HOUSE_AD vertex → NonRetryableError).

UI: list (with date-range chips), add (R2 upload via 03 endpoints under the house user), retire (sets retired_at), preview (renders the OG card via 07’s component).


Reads saga_runs joined on auctions and bids (all D1). Computes daily revenue (SUM(amount_cents) WHERE saga_runs.status='complete'), 30-day MRR projection, capture rate (paid / won per day), failure rate (failed + house-ad-fallback), top advertisers by lifetime revenue, average winning bid trend (28-day moving), cascade-depth distribution (histogram of attempt from saga_runs).

All queries cached in DO-memory in the Hono worker for 60 s (master §10.2 — KV/DO-mem cache hot path). CSV export per panel.


KV CONFIG (binding from 01 §6). Flag schema: { key, value, type:'bool'|'int'|'string', updated_at, updated_by_sub }. MVP keys: anti-snipe-default (04 reads on DO init), conversion-pixel-enabled (08), predict-the-price-enabled (09 dark-launch), house-ad-min-weight. At MVP any authenticated operator may write (authorization is the CF Access email allowlist, #309); the writer is captured in the audit row. A founder-only gate returns only when a real IdP reintroduces roles (§2). UI shows current value + KV-metadata version; write path PUT CONFIG[key] + audit row. Each consumer documents its own cache TTL; console surfaces “earliest propagation: ${ttl}” per flag.


Table admin_audit in D1 (binding DB). One row per write action across all admin routes. Columns: id (UUID), at (ISO ts), admin_sub (JWT sub, stable), admin_email (display), role (constant founder at MVP — see §2; per-role resolution returns only with a real IdP), action (kebab: ad.decision | saga.resume | bidder.strike | refund.requested | refund.completed | house-ad.create | feature-flag.write | …), target_id (entity id), target_kind (ad | saga | user | invoice | house_ad | flag), before (JSON), after (JSON), request_id (CF Ray id), outcome (ok | failed), error_code NULL. Index on (at DESC) and (admin_sub, at DESC) and (target_kind, target_id). /admin/audit paginates with cursor; read-only for every authenticated operator (no admin can hide their own actions).

#602: every KYC / user-row operator action (user.manual-approve | user.reject | user.clear-strikes | user.force-strike | user.ban | user.unban | user.vies-recheck | kyc.force-escalate | kyc.resolve-escalation.*) writes an admin_actions row with target_kind='user', target_id=<users.id>, the verified CF-Access actor (admin_sub/admin_email/role), and outcome='ok' — so they appear in /admin/audit (the legacy dead JSON routes that wrote user_admin_actions were deleted).


  1. E1 — Two admins approve same ad — gated by 03 §E9 (WHERE state='pending'). Loser sees ad.state-conflict. Admin UI reuses this; no If-Match.
  2. E2 — Feature-flag race — Two founders write the same key simultaneously: KV last-write-wins. UI re-reads on save; conflict banner if version metadata changed. Consumers see eventual consistency bounded by their own cache TTL (documented per flag in §11).
  3. E3 — CF Access JWT cache TTL surprise — JWKS cached 1 h; key rotation by Cloudflare can cause a brief verify failure. Middleware retries once with fresh fetch on kid-not-found; otherwise 503 with admin.jwt-rotation-retry.
  4. E4 — Stale moderation_claims TTL — admin closes tab without DELETE; the 10-min TTL releases the lock. Re-claim succeeds; both decisions guarded by E1.
  5. E5 — Saga resume on a completed instance — instance.status() === 'complete' → 409 saga.terminal; UI greys the button after status fetch.

  1. Hitting admin.nowyourlink.com without a CF Access JWT returns 302 to the Access login.
  2. (#309) Any verified JWT — i.e. an operator on the CF Access email allowlist — is admitted as the single founder super-admin; there is no 403 admin.role-unmapped. A request whose JWT fails verification, or is missing sub/email, still fails closed (401).
  3. (#309) Every authenticated operator can reach all surfaces, including /admin/queue, /admin/house-ads, and /admin/feature-flags (no per-route role guard).
  4. (#309) Every authenticated operator can both GET and POST /admin/refunds/:id.
  5. Approving a pending ad writes an admin_audit row with before.state='pending', after.state='approved', admin_sub matching the JWT.
  6. Issuing a full refund on a settled auction triggers the saga REFUND vertex within 5 s; credit-note PDF lands in R2 (per 05 §9); audit row updates to outcome='ok'.
  7. (#309) Saga resume by any authenticated operator triggers instance.restart(); the terminal-state guard still applies (E5 → 409 saga.terminal).
  8. (#309) A feature-flag write by any authenticated operator updates KV and writes an audit row.
  9. (#309) The live auction monitor renders amounts to every authenticated operator (all are founder). getRankedBids() stays gated behind the CF Access JWT (apps/api re-verifies aud), so the RPC cannot be reached without a valid admin token; amount-disclosure remains a server-side boundary, not a UI one.
  10. pickHouseAd("2030-01-01") with no matching house-ad row returns 404 — saga falls back to P0 alert (05 §11 E4).

RBAC reintroduction (future): when an IdP that emits group membership is stood up (see §2), criteria 3/4/7/8/9 tighten back to the per-role capability table. Until then the single-super-admin model above is the binding contract.


  • Worker: apps/admin is one Hono worker (master D40/D42; the §126 #340 callout corrects the original “Astro worker” framing). It renders the admin HTML server-side and consumes the design system through @nowyourlink/ui’s CVA *.variants.ts contract + @nowyourlink/design-tokens — never the .astro primitives (those need an Astro renderer; they stay apps/web-only). Bindings: DB, CONFIG, CREATIVES, service bindings MODERATION, IDENTITY, AUCTIONS, SAGA, HUB.
  • Middleware: src/middleware.ts verifies the JWT (jose) against the JWKS URL; sets locals.{sub, email, role}. Re-verify on every request — do not cache per-session.
  • Never fetch from the browser direct to api.* — all calls go through apps/admin server routes so the JWT can be forwarded.
  • D1 mirror reads only — never write saga_runs/bids/auctions here; mutations go via service-binding RPCs.
  • Sentry: tag every span with subsystem=admin-console, role, route; emit a breadcrumb on each mutation.
  • Open question: a “shadow” mode for founder actions (preview audit row + downstream signals without commit)? Deferred — needs dry-run support in 02/03/05.