Appearance
Admin access & authorization
What/why: Admin governance — what this screen manages and why it exists.
The NanaSelect /admin area is gated to NanaWall staff by Cloudflare Access (Zero Trust). (On the POC review host the whole site sits behind one site-wide Access app that this same machinery verifies — see POC access.) Identity comes from the Cloudflare Access JWT — no passwords are stored in the app — and a small D1 role table decides what each signed-in person may do. Authorization is always enforced server-side; the UI only reflects it.
How it works
request ──► Cloudflare Access (edge) ──► Worker
blocks anonymous users hooks.server.ts verifies the
(login required) Cf-Access-Jwt-Assertion JWT, maps
the email → role, sets locals.admin
│
/admin/+layout.server.ts turns that into
401 (no identity) / 403 (no grant) / ok- Edge (Cloudflare Access). In production, Access sits in front of the whole host (one site-wide app — see Production setup) and forces a login before any request reaches the Worker. It injects a signed JWT on every request it lets through.
- JWT verification (
src/lib/server/auth/jwt.ts). The Worker verifies that JWT with Web Crypto (RS256 against the team's JWKS athttps://<team>.cloudflareaccess.com/cdn-cgi/access/certs) and checks theaud,iss,exp, andnbfclaims. A forged or tampered token fails here. No JWT library is added — the app keeps its zero-runtime-dependency footprint. - Role lookup (
src/lib/server/auth/roles.ts). The verified email is looked up in theadmin_usersD1 table. No row → forbidden (authenticated at the edge, but not authorized for the admin). - Guard (
src/routes/admin/+layout.server.ts). Reads the decision fromevent.locals.admin(resolved once per request inhooks.server.ts) and returns401/403/ the signed-in identity accordingly.
Roles & capabilities
A role maps to capabilities; every server-side check asks "does this role have this capability?", never "is this a specific role?".
| Role | edit_data (systems, attributes, media, factors) | edit_rules (right-sizing) | view_audit (trail, analytics, leads) | manage_users (roster) |
|---|---|---|---|---|
data_editor | ✅ | — | ✅ | — |
rules_editor | — | ✅ | ✅ | — |
admin | ✅ | ✅ | ✅ | ✅ |
An unknown / legacy role grants nothing (fails closed). The matrix was reviewed against the incoming multi-admin duties (systems, attributes, images, rules — #899): they all map onto edit_data / edit_rules, so the three roles stand; manage_users is deliberately admin-only so an editor role can never grant access. Emails are stored and compared lower-cased.
Managing the roster
Grants live in the admin_users table (email, role, name) and are managed from /admin/users (the Admins section, manage_users capability): add by email, change roles, remove — every change audit-logged. Two protections are enforced server-side: the roster can never lose its last admin-role user, and removing or demoting yourself requires an explicit extra confirmation. Remember the grant is two-part — the Cloudflare Access policy must also admit the email, or the roster row never takes effect. Local seeds live in seed/sql/010-admin-users.sql.
Managing the knowledge base. Once signed in with the
edit_datacapability:
- managing-systems.md — the Systems screen: creating, retiring (soft delete), and editing systems and their typed attribute values.
- managing-attributes.md — the Attributes screen: the comparison-feature catalog, value types, and safe value-type changes.
- managing-factors.md — the Selection factors screen: the guided-flow questions, their options, and option → attribute mappings.
- managing-tracks.md — the Tracks screen: buyer-facing guided-flow variants at
/select/<slug>, their factor subsets and pre-seeded answers, and the draft → published → retired lifecycle.- managing-corpus.md — the Corpus screen: uploading, replacing, and retiring the product-expertise documents behind retrieval.
- audit-and-validation.md — how edits are validated before writing, and the append-only Audit trail screen (
view_audit).
Local development (dev bypass)
Cloudflare Access is not in front of the local dev server, so the app provides a bypass that is compile-time impossible to enable in production: it keys off SvelteKit's dev flag, which is statically false in the built Worker.
Running
npm run dev(or/dev-up) signs you into/adminautomatically as the seeded superuseradmin@test.local(roleadmin) — zero config.To exercise a different role, copy
.dev.vars.exampleto.dev.varsand setADMIN_DEV_EMAILto one of the seeded users, then restart the dev server:Email Role admin@test.localadmindata@test.localdata_editorrules@test.localrules_editor
.dev.vars is git-ignored and never deployed.
Production setup (as built — live since 2026-07-14, #898)
The edge protection is a Cloudflare Zero Trust configuration in the Paul@iwpi.com's Account account (bb4c9b919e317b42c003591ab2cc4ecd); the dashboard-side pieces were human-provisioned (todos #939 → #1152 → #1324) and the app half is wired in the repo. As built:
- Access application: one site-wide self-hosted app covering
nanaselect.iwpi.com— not a separate/admin*app. One app means one AUD for the whole host (#1152), so the same JWT verifies for the POC review gate and the admin. The*.workers.devand preview hosts are disabled instead of gated, because Access cannot sit in front of*.workers.dev(#1059). When the app moves to its production.nanawall.comsubdomain, the same one-app-one-AUD structure carries over (add the hostname, keep a single app). - Login method: One-time PIN (email OTP) — staff enter their email and a code; no IdP and no passwords.
- Access policy: admits the current NanaWall staff emails (
paul@iwpi.comverified end-to-end). - Worker vars:
CF_ACCESS_TEAM_DOMAIN=iwpi.cloudflareaccess.comandCF_ACCESS_AUDare committed inwrangler.jsoncvars(commit878dc6e) and deployed (#1324). - Roster: production
admin_usersholdspaul@iwpi.com→admin(managed at/admin/users; the Access policy and the roster are both required — see Managing the roster).
Verified live (2026-07-14, record on #560): an anonymous request to /admin gets a 302 to the iwpi.cloudflareaccess.com login; an allowed + rostered email lands in the admin shell with the correct role; the dev bypass still works under vite dev and is compiled out of the built Worker (env.ADMIN_DEV_BYPASS ?? void 0 — no dev-flag path remains, and the explicit-var path additionally requires an unset CF_ACCESS_TEAM_DOMAIN, which production always defines).
Still outstanding: the allowed-but-un-rostered → 403 negative path is code-level verified in src/routes/admin/+layout.server.ts but has not been exercised live — it needs a second Access-admitted identity that is not in the roster (human-owned; tracked as a todo on #898).
Adding a new staff member (repeatable)
- Add the email to the Access policy in the Zero Trust dashboard (or it never reaches the app).
- Add the same email to the roster at
/admin/userswith the right role.
Session surface (#900). The admin shell shows the signed-in identity + role (from event.locals.admin), a distinct badge for dev-bypass sessions, and a Sign out link to Cloudflare's Access logout endpoint (/cdn-cgi/access/logout) — Access owns the session; the app only hands off. Verified on the gated POC host that the edge answers that path (a sessionless request gets Access's empty 404, not the origin's HTML). The end-to-end check — sign out under a real OTP session and land back at the Access login — is the one #900 AC still open; it rides on the publish todo #1157, since it needs the deployed Worker behind Access rather than the dev-bypass tunnel.