Admin portal for managing BT Servant worker configurations. Built with React 19 and deployed to Cloudflare Workers.
The portal is the admin frontend for bt-servant-worker's admin API (referred to as "the engine" throughout the code — ENGINE_BASE_URL points at it). It also talks to baruch, the conversational configuration agent, via a service binding.
- Frontend: React 19, TypeScript (strict), Vite, Tailwind CSS 4
- UI: Radix UI primitives, shadcn-style components, Font Awesome Pro (duotone/light/solid), Lucide React
- Editor: CodeMirror 6 (markdown), react-markdown + remark-gfm for rendering
- Routing: React Router v7 (client-side SPA, data router)
- State: TanStack React Query v5 (server), Zustand v5 (client)
- Backend: Cloudflare Worker BFF (auth + API proxy to the BT Servant worker and Baruch)
- Auth: Session-based with KV storage (PBKDF2 password hashing, HttpOnly cookie, 7-day TTL)
- Tests: Vitest via
@cloudflare/vitest-pool-workers(worker code runs in the real workerd runtime) - Font: Outfit
Onion architecture enforced by ESLint no-restricted-imports:
src/
├── types/ → Domain types (no internal imports)
├── lib/ → Core utilities (can import: types)
├── hooks/ → Business logic hooks (can import: lib, types)
├── components/ → UI components (can import: hooks, lib, types)
└── app/ → Routes & pages (can import: all layers)
worker/
├── index.ts → Cloudflare Worker entrypoint & router
├── auth.ts → Login/logout/session/change-password, session hydration + lazy rights migration
├── chat.ts → Chat SSE streaming, history & memory proxy (with user_id override guard)
├── baruch.ts → Baruch SSE streaming, initiation & history proxy
├── config.ts → Modes, languages & prompt-override proxy + verb-perms authorization gate
├── admin.ts → Admin user CRUD (tri-mode auth: secret / super admin / org admin)
├── rights-migration.ts → Per-user rights migration for rename, plus the mode clone auto-grant
├── crypto.ts → PBKDF2 hashing, constant-time compare
├── helpers.ts → Response helpers, same-origin guard, KV listing, org-shape guard
└── types.ts → StoredUser / SessionData shapes
| Route | Page | Access | Description |
|---|---|---|---|
/login |
Login | Public | Email/password authentication |
/ |
Baruch | Any session | Conversational config assistant |
/modes |
Modes | Admins, or shepherds with any mode edit/publish rights | Markdown editor for prompt modes: create, publish, rename, clone, retire, delete, export |
/languages |
Languages | Admins, or shepherds with any language edit/publish rights | Markdown editor for per-language tuning documents (new drafts seeded from the scaffold) |
/prompt-configuration |
Prompt Overrides | Admin only (direct URL — no sidebar entry) | Org-wide prompt-override slots; includes the user-memory viewer |
/admin/users |
Users | Admin only | Manage users and their rights (org-scoped; super admins see all orgs) |
Shared page features:
- Markdown editor (Modes + Languages): CodeMirror with heading TOC, debounced autosave (800 ms), manual Save flush, unsaved-changes guards on navigation / selection switch / org-context switch, and read-only rendering when the user lacks edit rights on the selected row.
- Language visibility (#249): the Languages dropdown lists every draft in the org for anyone who can reach the page — rows the user holds no edit right on open read-only. Modes still list only the rows a non-admin shepherd holds a verb on.
- Org context selector (Modes, Languages, Prompt Overrides): super admins can switch the pages to any org that has at least one user; the choice persists across the three pages and is sent as
?org=to the BFF. - Test chat panel: slide-out BT Servant chat (SSE) available on every page. Uses a synthetic per-session test user ID so test conversations never touch the caller's own history, with a mode picker that pins the test user's active mode.
- User menu: change password, light/dark theme toggle, sign out, app version.
The engine trusts the portal with a single shared API token, so this worker's BFF is the enforcement point for per-user authorization.
- Org admin (
isAdmin): manages users in their own org; can read, create, edit, publish and delete any mode or language in their org (admin trump — per-row verb rights scope non-admin shepherds only). - Super admin (
isSuperAdmin): cross-org powers — sees/manages users in every org, moves users between orgs, grants/revokesisSuperAdmin, and bypasses per-row gates when operating on another org's config (their home-org rights stay enforced at home). - Verb rights (per user, per resource):
language_edit_rights,language_publish_rights,mode_edit_rights,mode_publish_rights— each either"*"or an array of slugs, assigned via a four-selector matrix in the user dialogs. Edit gates document/label/description changes; publish gates the published flag; delete requires both. - Legacy fallback: the pre-verb-perms
language_rightsfield is lazily migrated at session time — it applies only when both language verb fields are unset (setting one verb makes the unset partner an explicit deny, not legacy-full). Modes have no legacy fallback: a non-admin with both mode fields unset has no mode access. - Sessions re-hydrate from the live user record on every request, so grants, revocations, and org moves take effect immediately (no re-login needed).
The Modes page drives the engine's _rename, _clone, and _retire ops through the BFF, which layers rights handling on top:
- Rename (needs edit on the source): reslugs the mode in place; the engine keeps the old slug as an alias so end-user assignments aren't stranded. The BFF migrates every same-org user's stored mode rights old-slug→new-slug around the engine call (expand → rename → contract, superset-on-ambiguity so failures can never strand a shepherd).
- Clone (needs edit on the source): copies content to a new unpublished slug. Non-admin cloners are auto-granted the verbs they hold on the source (edit always; publish only if held) on the new slug, signalled back via
X-Bootstrap-Grantand mirrored into the client session. - Retire (needs edit + publish on the source, plus edit on the forward target): moves the retired slug (and its aliases) onto the target's alias list and deletes the source — subscribers silently resolve to the target.
- Export: downloads the selected mode (including unsaved edits, label, description, aliases) as a frontmattered Markdown snapshot.
All three ops run fail-closed preflights against the engine (alias-addressing and slug-collision checks) before any rights mutation.
npm install # Install dependencies (Font Awesome Pro registry token required)
npm run dev # Start Vite dev server
npm run build # Production build
npm run preview # Preview via Cloudflare Workers runtimenpm run typecheck # TypeScript type check (project references)
npm run lint # ESLint (zero warnings)
npm run lint:fix # ESLint with auto-fix
npm run format # Prettier format
npm run format:check # Prettier check (CI)
npm test # Vitest (worker pool — BFF integration tests + lib unit tests)
npm run test:watch # Vitest watch modePre-commit hooks (via Husky + lint-staged) run ESLint, Prettier, typecheck, and build on every commit. CI additionally runs secret scanning (gitleaks), the test suite, a production-dependency audit, and a bundle-size report.
| Environment | Deploys on | Worker name | Engine API |
|---|---|---|---|
| Per-PR dev | PR opened/updated (deploy-pr.yml) |
bt-servant-admin-portal-pr-<N> |
staging-api.btservant.ai |
| Staging | PR merged to main (deploy-staging.yml) |
bt-servant-admin-portal-staging |
staging-api.btservant.ai |
| Production | Manual workflow dispatch (deploy-prod.yml) |
bt-servant-admin-portal |
api.btservant.ai |
Each open PR gets its own ephemeral worker (URL commented on the PR), torn down by cleanup-pr.yml when the PR closes. Docs/mockup-only changes skip deploys. There are no direct deploys from local machines.
Worker configuration (wrangler.jsonc): ENGINE_BASE_URL / BARUCH_BASE_URL vars, AUTH_KV KV namespace (users + sessions + login rate limits), and a BARUCH service binding (used when available to avoid same-zone subrequest restrictions). Secrets: ENGINE_API_KEY, BARUCH_API_KEY, ADMIN_SECRET.
The Cloudflare Worker acts as a BFF, authenticating requests (session cookie + X-Requested-With same-origin guard) and forwarding to the engine or Baruch with the shared API keys.
| Route | Auth | Description |
|---|---|---|
/api/auth/* |
None (login) / session | Login, logout, session check (/me), change password |
/api/admin/users[/:email] |
X-Admin-Secret OR admin/super-admin session |
User CRUD with org scoping and self-lockout guards |
/api/chat/stream |
Session | BT Servant SSE streaming |
/api/chat/history |
Session | GET/DELETE chat history |
/api/chat/memory |
Session | DELETE user memory |
/api/baruch/* |
Session | Baruch SSE streaming, initiation, history (GET/DELETE) |
/api/config/prompt-overrides |
Session (writes: admin) | Org prompt-override slots |
/api/config/modes[/:name] |
Session (writes: verb-perms, admin trump) | List/read/write/delete modes |
/api/config/modes/:name/_rename |
Admin or edit-on-source | Rename + per-user rights migration |
/api/config/modes/:name/_clone |
Admin or edit-on-source | Clone + cloner auto-grant |
/api/config/modes/:name/_retire |
Admin, or edit+publish-on-source + edit-on-target | Retire-and-forward |
/api/config/languages[/:name] |
Session (writes: verb-perms, admin trump) | List/read/write/delete language documents |
/api/config/language-scaffold |
Session (read-only) | Org scaffold template for new language drafts |
/api/config/user-mode/:userId |
Session | PUT/DELETE the test-chat user's active mode |
/api/config/user-memory/:userId |
Session | GET/DELETE a user's persistent memory |
Notes:
- All
/api/config/*routes accept?org=<slug>— same-org values resolve as if absent; a different org requiresisSuperAdmin(rejected loudly otherwise). - Chat
user_idoverrides (used by the test-chat panel's synthetic IDs) are verified against the stored-user list so no authenticated user can read or delete a colleague's history/memory. - Login and change-password are rate-limited per IP via KV (10 attempts / 5 minutes).
An "org" in the portal is a free-text string on each user record — it "exists" the moment the first user with that org string is created. Two paths:
- From the UI as a super admin — on
/admin/usersthe create-user dialog has an editable Org field for super admins. Typing a new slug creates that org with this user as a member. (See "Bootstrapping a super admin" below for how to get the first super admin.) - Via the CLI with
X-Admin-Secret— the recovery / CI path, also used before any super admin exists in an environment.
# Source ADMIN_SECRET from your password manager (1Password example below)
op run -- npm run create-org-admin -- \
--env staging \
--org haneen \
--email haneen@example.com \
--name "Haneen <last>"
# Other useful flags:
# --password '...' supply your own (default: auto-generate 16-char)
# --rights '*' "*" (default), "none", or comma-separated language slugs
# --not-admin create a non-admin member (default: admin)
# --confirm-prod required when --env prod
# --url <full-url> override the portal URL (e.g. custom domain)
# --dry-run print the request without sending
# --help full usage
ADMIN_SECRET=… npm run create-org-admin -- --helpOn success the script prints the created user and the initial password — share both out-of-band; the user can change the password after first sign-in. ADMIN_SECRET is the portal worker's wrangler secret (see worker/admin.ts for the auth model); it is never echoed back.
Once the org's first admin exists, they can create their org's first language draft directly from the Languages page — the worker allows same-org admins to create drafts that don't exist yet and auto-grants them edit + publish on the new slug (the user dialogs also surface a "no drafts yet" CTA pointing at the Languages page).
A super admin has cross-org powers: they see users across every org on /admin/users, can create users in any org, can move users between orgs, and can grant/revoke isSuperAdmin on others. This is intentionally a small set of people (typically the maintainers).
The first super admin in any environment is granted via a one-time curl against the portal worker with X-Admin-Secret. After that, super admins can grant the role to each other from the UI.
op run -- curl -X PUT \
https://bt-servant-admin-portal-staging.unfoldingword.workers.dev/api/admin/users/seth@example.com \
-H "X-Admin-Secret: $ADMIN_SECRET" \
-H "Content-Type: application/json" \
-d '{"isSuperAdmin": true}'The grant takes effect on the user's next request (the session re-hydrates from the live user record on every request, so no logout/login is required — but they may need to refresh the page to see the new UI affordances).
Revocation works the same way with {"isSuperAdmin": false}. A super admin cannot self-revoke from the UI (the worker rejects with 400 to prevent locking yourself out); use the CLI for that.