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/memberships.ts workspace members
├── /team api/cms.ts public team profiles
├── /blogs /info-page /media-library... api/cms.ts / api/media.ts
└── /v1/* public storefront API (api-key + public pages)
▼
D1 (fullbleed) + R2 (MEDIA)
The members router lives in
api/memberships.ts(route/api/members) to dodge a dev-server quirk — see "Dev server gotchas". It wasapi/team.tsuntil/api/teambecame the Team profiles route, which that file name then shadowed in dev.
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:
workspaceFor(c, slug?)/sessionFor(token, slug?)take an optional workspace slug, and fall back to the caller's default workspace when it is omitted: one they own first, then newest (role = 'owner' DESC, created_at DESC, rowid DESC LIMIT 1).workspaceForreads the slug from its argument, else?workspace=, else the same-originReferer(/workspace/<slug>/…), so dashboard fetches are scoped to the page that issued them without each call passing the slug; membership is still checked. The dashboard layout re-resolves scoped to its own[slug], so members of several workspaces are not bounced to their default one. - One account per email. Someone in several workspaces switches between them
with the sidebar workspace switcher (
components/workspace-switcher.tsx;workspacesFor(token)lists their memberships with role). The layout writes the open slug to thefullbleed_workspacecookie, and after loginlandingSessionForlands there if they are still a member, else on the default workspace. - 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/memberships.ts). Every route accepts?workspace=<slug>to scope to a specific workspace:GET /→ members.POST /{name, email, password, role?}(owner only) → adds a member directly. There is no invite flow and no email: for a new email it creates the user (password ≥ 12 chars,email_verified_atset) plus the membership in one batch; the owner shares the credentials out of band and the member logs in straight into this workspace — they never sign up or create a workspace. An email that already has an account is just added (its password is left untouched). Responsecreatedsays which happened.roledefaults toeditor.- Owner-only:
PATCH /:userIdrole changes,DELETE /:userId. Last-owner/self-demote/self-remove are blocked.
workspaces.member_limit(5) caps members, enforced on add. The legacyinvitationstable is no longer read or written.
Team profiles (not members)
- Separate from members:
team_membersrows are public staff profiles for the website, not logins (migrations/0009_team_members.sql). Page:app/(dash)/workspace/[slug]/team. /api/teaminapi/cms.ts, workspace-scoped like the rest (owners and editors):GET /(paginated, byposition),POST /(appends:MAX(position) + 1),PATCH /:id,DELETE /:id, andPUT /order{ids}, which must list every member of the workspace exactly once (else 409, so a stale page can't scramble the order) and rewritespositionto each id's index. The page loads up to 100 members unpaginated so all of them can be dragged (dnd-kit, keyboard-accessible).positioncolumn:migrations/0010_team_member_position.sql(backfilled by creation order). Body{name, role, profession?, qualification?, description?, license?, image?};nameandroleare required, blank optionals are stored asNULL.imageis a media-library URL from the media picker.- Public:
GET /api/v1/team(lib/server/public-api.ts), unpaginated, byposition(returned as a field);imagemade absolute on the media domain, blank optionalsnull.
/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
scripts/members-check.sh, scripts/content-check.sh and scripts/media-check.sh —
point any of them at the reported port
(./scripts/members-check.sh http://localhost:<port>).
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/memberships.tsbut the route is/api/members(and why it can't beapi/team.tsnow that/api/teamexists). 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./scripts/members-check.sh http://localhost:<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.