Keybud Architecture Guide
How the admin dashboard works end to end: UI, API, data, and the dev/prod split.
Big picture
One Cloudflare Worker (built with Next.js via vinext + Hono) serves both the
admin UI and the API. The UI is a Next.js App Router app; every /api/*
request is answered by a Hono router that runs in the same process and talks
to a D1 database and R2 storage.
Browser (admin UI on :3001 dev / :8787 prod)
│ fetch("/api/...", { credentials: "include" })
▼
Next.js route handler: app/api/[[...path]]/route.ts ← the "catch-all"
│ api.fetch(request, env) ← Hono, in-process
▼
api/app.ts (mounts all feature routers)
├── /auth/* app/api/auth/* (Next route handlers)
├── /admin api/platform.ts platform admin (users, suspend)
├── /members api/team.ts workspace members + invites
├── /blogs /info-page /media-library... api/cms.ts / api/media.ts
└── /v1/* public storefront API (api-key + public pages)
▼
D1 (fullbleed) + R2 (MEDIA)
The
api/members.tsfile was renamed toapi/team.ts(route stays/api/members) to dodge a dev-server quirk — see "Dev server gotchas".
Request flow in detail
- Client (
lib/api.ts→apiFetch, plus inlinefetchin pages): always a relative/api/...URL withcredentials: "include"(session cookiefullbleed_session). - Next catch-all
app/api/[[...path]]/route.tsmatches any/api/*that isn't a more specific file (e.g./api/auth/login,/api/health). It callsapi.fetch(request, env)with theenvbindings fromcloudflare:workers. - Hono (
api/app.ts) dispatches by path to the feature routers. Everything except/v1/*requires a session; routers callworkspaceFor(c)/sessionFor(c)fromapi/workspace.tsto scopes queries to the caller's current workspace. - Storage: D1 via
c.env.DB.prepare(...); media uploads to R2 via theMEDIAbinding. Config inwrangler.jsonc.
Response envelopes
Success: { data: ... } (lists carry a pagination object). Errors:
{ error: "..." } with 4xx/5xx. The E2E tests rely on this shape.
Auth & session
- Login/signup/logout are Next route handlers (
app/api/auth/*) using real password hashing (lib/server/auth.ts) and afullbleed_sessioncookie. - Every other API validates the session, then resolves the current workspace
= the caller's most recent
membershipsrow (created_at DESC, rowid DESC LIMIT 1). Invited editors land on the workspace they were invited to. - Suspended users (
users.disabled_atset) are rejected at login (403) and their existing sessions stop resolving workspaces. - Roles:
owner(full CRUD + member management) andeditor(content only). Platform admins (users.is_platform_admin) manage all users from/admin— they are NOT staff accounts; they are the people who control access/moderation.
Workspaces, teams, members
- A top-level next-page loads the workspace, then a
/workspace/[slug]group contains dashboard, posts/pages, media, members, etc. The sidebar routes live incomponents/app-sidebar.tsx. /api/members(fileapi/team.ts):GET /→ members + pending invitations.POST /{email}→ creates a 7-day invitation; returns the one-timetoken. Token is only shown once (DB stores its SHA-256 hash) — there is intentionally no "resend".GET /invitations/:token→ public metadata for the invite screen,/invite/[token].POST /accept{token}→ joins the workspace (creates membership).- Owner-only:
PATCH /:userIdrole changes,DELETE /:userId, andDELETE /invitations/:id. Last-owner/self-demote/self-remove are blocked.
/admin (platform admin)
app/admin/layout.tsx+page.tsxguardis_platform_admin; other users get bounced to their own dashboard.GET /adminused to be a/loginredirect stub.api/platform.tsat/api/admin:GET /users(search by name/email, paginated; includes each user's memberships).PATCH /users/:id— grant/revoke platform admin.PATCH /users/:id/status— suspend/unsuspend.- Self-changes and self-suspend are blocked with 400.
- To promote a user: set
PLATFORM_ADMIN_EMAILinwrangler.jsonc(register auto-promotes that address), or flipis_platform_admindirectly.
Public storefront API (/api/v1/*)
Storefronts read published content read-only using an API key
(api_keys table, Authorization: Bearer kb_pub_*). Implemented in
lib/server/public-api.ts via api.all("/v1/*", ...) in api/app.ts.
CORS is per-origin via allowed_origins. Full route list in api/README.md.
Dev vs production
Dev (pnpm dev, port 3001) |
Prod (deploy or pnpm start) |
|
|---|---|---|
| API resolution | app/api/** routes + catch-all, served by the vinext/vite dev server |
the compiled Worker |
| Storage | local D1 via wrangler (pnpm exec wrangler d1 migrations apply fullbleed --local) |
remote D1/R2 |
Both run the same api/app.ts. The production path is proven by the E2E
script (/tmp/fullbleed-members-test.sh → pnpm build && pnpm start, read the
port from /tmp/fullbleed-wrangler.log, run with BASE=http://localhost:<port>/api).
Dev server gotchas
- File-name vs URL collision: vite dev serves the source of a project TS
file when the URL path matches
api/<name>.ts./api/membersmatchedapi/members.tsand returned its source (import { Hono }...) → the browser errorUnexpected token 'i', "import { H"... is not valid JSON./api/healthand/api/admin/userswork because noapp/api/health-or-api/admin/users.tsfile collides. Workaround: avoidapi/<segment>.tsnames that equal a public API path. This is why the file isapi/team.tsbut the route is/api/members. After renaming, restartpnpm dev(vite caches the mapping). - Port 3000 belongs to other projects. Some setups run unrelated backends on
:3000; if
/api/*responses start looking foreign (Express "Cannot GET...",{"site":"localhost"}health echoes), kill whatever squats on the port — fullbleed never proxies anywhere. - LSP errors like
Cannot find module '@/components/...'orUint8Array/BufferSourceinlib/server/auth.tsare stale/non-gating;pnpm buildis the source of truth. Grep output sometimes mangleslog→non this machine; confirm strings by reading the file.
Change checklist
- Edit code, then
pnpm exec eslint <files>(0 errors required; old warnings OK). pnpm build(import errors here are real).pnpm start, then run/tmp/fullbleed-members-test.shagainst the reported port.- Confirm the affected page in
pnpm dev(restart it if you renamed/moved files). - Migrations: write
migrations/NNNN_name.sql, apply withpnpm exec wrangler d1 migrations apply fullbleed --local.