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 was api/team.ts until /api/team became the Team profiles route, which that file name then shadowed in dev.

Request flow in detail

  1. Client (lib/api.ts → apiFetch, plus inline fetch in pages): always a relative /api/... URL with credentials: "include" (session cookie fullbleed_session).
  2. Next catch-all app/api/[[...path]]/route.ts matches any /api/* that isn't a more specific file (e.g. /api/auth/login, /api/health). It calls api.fetch(request, env) with the env bindings from cloudflare:workers.
  3. Hono (api/app.ts) dispatches by path to the feature routers. Everything except /v1/* requires a session; routers call workspaceFor(c) / sessionFor(c) from api/workspace.ts to scopes queries to the caller's current workspace.
  4. Storage: D1 via c.env.DB.prepare(...); media uploads to R2 via the MEDIA binding. Config in wrangler.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 a fullbleed_session cookie.
  • 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). workspaceFor reads the slug from its argument, else ?workspace=, else the same-origin Referer (/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 the fullbleed_workspace cookie, and after login landingSessionFor lands there if they are still a member, else on the default workspace.
  • Suspended users (users.disabled_at set) are rejected at login (403) and their existing sessions stop resolving workspaces.
  • Roles: owner (full CRUD + member management) and editor (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 in components/app-sidebar.tsx.
  • /api/members (file api/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_at set) 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). Response created says which happened. role defaults to editor.
    • Owner-only: PATCH /:userId role changes, DELETE /:userId. Last-owner/self-demote/self-remove are blocked.
  • workspaces.member_limit (5) caps members, enforced on add. The legacy invitations table is no longer read or written.

Team profiles (not members)

  • Separate from members: team_members rows are public staff profiles for the website, not logins (migrations/0009_team_members.sql). Page: app/(dash)/workspace/[slug]/team.
  • /api/team in api/cms.ts, workspace-scoped like the rest (owners and editors): GET / (paginated, by position), POST / (appends: MAX(position) + 1), PATCH /:id, DELETE /:id, and PUT /order {ids}, which must list every member of the workspace exactly once (else 409, so a stale page can't scramble the order) and rewrites position to each id's index. The page loads up to 100 members unpaginated so all of them can be dragged (dnd-kit, keyboard-accessible). position column: migrations/0010_team_member_position.sql (backfilled by creation order). Body {name, role, profession?, qualification?, description?, license?, image?}; name and role are required, blank optionals are stored as NULL. image is a media-library URL from the media picker.
  • Public: GET /api/v1/team (lib/server/public-api.ts), unpaginated, by position (returned as a field); image made absolute on the media domain, blank optionals null.

/admin (platform admin)

  • app/admin/layout.tsx + page.tsx guard is_platform_admin; other users get bounced to their own dashboard. GET /admin used to be a /login redirect stub.
  • api/platform.ts at /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_EMAIL in wrangler.jsonc (register auto-promotes that address), or flip is_platform_admin directly.

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/members matched api/members.ts and returned its source (import { Hono }...) → the browser error Unexpected token 'i', "import { H"... is not valid JSON. /api/health and /api/admin/users work because no app/api/health-or-api/admin/users.ts file collides. Workaround: avoid api/<segment>.ts names that equal a public API path. This is why the file is api/memberships.ts but the route is /api/members (and why it can't be api/team.ts now that /api/team exists). After renaming, restart pnpm 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/...' or Uint8Array/ BufferSource in lib/server/auth.ts are stale/non-gating; pnpm build is the source of truth. Grep output sometimes mangles log→n on this machine; confirm strings by reading the file.

Change checklist

  1. Edit code, then pnpm exec eslint <files> (0 errors required; old warnings OK).
  2. pnpm build (import errors here are real).
  3. pnpm start, then run ./scripts/members-check.sh http://localhost:<port>.
  4. Confirm the affected page in pnpm dev (restart it if you renamed/moved files).
  5. Migrations: write migrations/NNNN_name.sql, apply with pnpm exec wrangler d1 migrations apply fullbleed --local.