10 — Admin Console (Sub-system Design)
10 — Admin Console (Sub-system Design)
Section titled “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.
1. Purpose & scope
Section titled “1. Purpose & scope”In scope
Section titled “In scope”apps/adminHono worker atadmin.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.astroprimitives (master D42).- 14 routes (§3) backed by service-binding RPCs into 02/03/04/05/06 and D1 reads via the
DBbinding. - House-ad CMS — the only content owned natively by 10; backs the reserved
users.id='house'author that 03 defines and 05’spickHouseAd(auction_id)reads. admin_auditD1 table; write path on every mutating route.- Feature-flag UI on top of KV
CONFIG(binding from 01 §6).
Out of scope
Section titled “Out of scope”- Bid validation, ranking (
04); payment/credit-note calls (05owns Stripe); moderation auto-filter (03); session minting (02); WS broadcast (06). The admin console reads and signals; it does not implement.
2. CF Access integration (D15)
Section titled “2. CF Access integration (D15)”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;subis the stable identity.apps/admin/src/cf-access.tsverifies the JWT againsthttps://${env.CF_ACCESS_TEAM}/cdn-cgi/access/certswithaud = 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
groupsclaim 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’sCf-Access-Jwt-Assertion. Thegroupsclaim 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 returned403 admin.role-unmappedfor 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.comwith 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) | Role | Capabilities (future) |
|---|---|---|
nyl-founders | founder | Everything, incl. saga resume/cancel, feature flags |
nyl-admins | admin | Everything except feature-flag writes and saga resume/cancel |
nyl-moderators | moderator | /admin/queue, /admin/house-ads, read-only on /admin/audit |
nyl-finance | finance | Read-only on /admin/refunds, /admin/revenue, /admin/audit |
3. Route map (14)
Section titled “3. Route map (14)”| Route | Roles | Purpose |
|---|---|---|
/admin | all | Dashboard tiles: queue depth, sagas running, today’s Spotlight |
/admin/queue | moderator+ | Moderation queue (consumes 03 §6) |
/admin/auctions | admin+ | 30-day calendar live monitor (subscribes 06 lobby) |
/admin/auctions/:date | admin+ | Per-auction detail (ranked bid list, anti-snipe state) |
/admin/sagas | admin+ | List Workflow instances (reads saga_runs 05 §2) |
/admin/sagas/:instance_id | admin+ (resume/cancel = founder) | Vertex history, retry counts, last error |
/admin/bidders | admin+ | Search by email/VAT-ID/company |
/admin/bidders/:user_id | admin+ | Bidder card + strike/ban/Identity-escalation actions |
/admin/refunds | admin+ (POST), finance (GET) | Credit-note issuance (sequence: processes/10-manual-refund.md) |
/admin/house-ads | moderator+ | CMS for reserved users.id='house' ads |
/admin/revenue | admin+, finance | MRR, capture rate, top advertisers |
/admin/analytics | admin+, finance | Traffic roll-ups (CF Web Analytics + pixel) |
/admin/feature-flags | founder | KV CONFIG editor |
/admin/audit | all (read-only) | Paginated admin_audit view |
4. Moderation queue UI
Section titled “4. Moderation queue UI”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.
5. Live auction monitor
Section titled “5. Live auction monitor”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.
6. Saga inspector
Section titled “6. Saga inspector”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.
7. Bidder management
Section titled “7. Bidder management”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 anadmin_actionsrow (§12). Retention matches the otheruser_id-keyed ledgers (saga_runs,invoices,saga_strikes): rows persist as pseudonymous facts keyed on the opaqueusers.id; a data-rights erase anonymises theusersrow in place (spec 12), severing the id from any identity, souser_bidsneeds no erase handling of its own. Actions (all → 02’s/admin/users/:id/*, audit-logged): Force strike, Unban (clearsbanned_at, retains strike row), Force Stripe Identity (openskyc_escalations), Manual VIES re-check (enqueues 02 §4 retry).
8. Refund tool
Section titled “8. Refund tool”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.
9. House-ad CMS
Section titled “9. House-ad CMS”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 listedapps/adminas an Astro worker with aSAGAbinding). The as-builtapps/adminis a Hono worker with noWorkerEntrypoint, and the payment saga (05, onapps/api) already holds theDBbindinghouse_adslives in. SopickHouseAdships as a plainD1Databasefunction in@nowyourlink/users/house-ads(10-owned selection logic; migration0017_house_ads.sql), called directly by the saga via itsDBbinding — the same shape #338’sloadRankedBiddersreads D1users.apps/admin’s house-ad CMS (its PR-6) imports the same function. Routing a plainSELECTthrough 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 markerdaily_spotlight.ad_id LIKE 'house:%'(07 §3 E1, which suppresses the tracked/goredirect so a house-ad day never pollutes advertiser click metrics). Authoritative rule: a house ad is anadsrow authored byusers.id='house';house_ads.ad_idreferences that normal UUIDads.id(its generation in 03 is untouched — thehouse:prefix is not part ofads.id). Thehouse:prefix is a SPOTLIGHT-LAYER marker:pickHouseAdreturns the selected ad’s id with the prefix applied (house:<ads.id>), i.e. the exact value the 04 settlement writer persists todaily_spotlight.ad_idon the HOUSE_AD-cascade outcome, so the public site recognizes it and 404s/go(07 §3 E1). The encoding is reversible (stripHousePrefixrecoversads.id). Both consumers — moderation’susers.id='house'auto-filter skip (03 §4.4) and the web’sad_id LIKE 'house:%'check (07 §3) — stay unchanged and no longer diverge. No match →pickHouseAdreturnsnulland the saga raises the P0 (§14 acceptance item 10; 05 §3HOUSE_ADvertex →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).
10. Revenue dashboard
Section titled “10. Revenue dashboard”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.
11. Feature-flag console
Section titled “11. Feature-flag console”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.
12. Audit log
Section titled “12. Audit log”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 anadmin_actionsrow withtarget_kind='user',target_id=<users.id>, the verified CF-Access actor (admin_sub/admin_email/role), andoutcome='ok'— so they appear in/admin/audit(the legacy dead JSON routes that wroteuser_admin_actionswere deleted).
13. Edge cases
Section titled “13. Edge cases”- E1 — Two admins approve same ad — gated by 03 §E9 (
WHERE state='pending'). Loser seesad.state-conflict. Admin UI reuses this; noIf-Match. - 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).
- 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 withadmin.jwt-rotation-retry. - 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. - E5 — Saga resume on a completed instance —
instance.status() === 'complete'→ 409saga.terminal; UI greys the button after status fetch.
14. Acceptance criteria
Section titled “14. Acceptance criteria”- Hitting
admin.nowyourlink.comwithout a CF Access JWT returns 302 to the Access login. - (#309) Any verified JWT — i.e. an operator on the CF Access email allowlist — is admitted as the single
foundersuper-admin; there is no403 admin.role-unmapped. A request whose JWT fails verification, or is missingsub/email, still fails closed (401). - (#309) Every authenticated operator can reach all surfaces, including
/admin/queue,/admin/house-ads, and/admin/feature-flags(no per-route role guard). - (#309) Every authenticated operator can both
GETandPOST /admin/refunds/:id. - Approving a pending ad writes an
admin_auditrow withbefore.state='pending',after.state='approved',admin_submatching the JWT. - Issuing a full refund on a settled auction triggers the saga
REFUNDvertex within 5 s; credit-note PDF lands in R2 (per 05 §9); audit row updates tooutcome='ok'. - (#309) Saga resume by any authenticated operator triggers
instance.restart(); the terminal-state guard still applies (E5 → 409saga.terminal). - (#309) A feature-flag write by any authenticated operator updates KV and writes an audit row.
- (#309) The live auction monitor renders amounts to every authenticated operator (all are
founder).getRankedBids()stays gated behind the CF Access JWT (apps/apire-verifiesaud), so the RPC cannot be reached without a valid admin token; amount-disclosure remains a server-side boundary, not a UI one. 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.
15. Agent-team hints
Section titled “15. Agent-team hints”- Worker:
apps/adminis 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.tscontract +@nowyourlink/design-tokens— never the.astroprimitives (those need an Astro renderer; they stayapps/web-only). Bindings:DB,CONFIG,CREATIVES, service bindingsMODERATION,IDENTITY,AUCTIONS,SAGA,HUB. - Middleware:
src/middleware.tsverifies the JWT (jose) against the JWKS URL; setslocals.{sub, email, role}. Re-verify on every request — do not cache per-session. - Never fetch from the browser direct to
api.*— all calls go throughapps/adminserver routes so the JWT can be forwarded. - D1 mirror reads only — never write
saga_runs/bids/auctionshere; 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.