Skip to main content

Architecture

Tech stack

LayerChoice
RuntimeNode.js >= 22 (ESM modules throughout)
LanguageTypeScript 5.x, strict mode, verbatimModuleSyntax
Package managerpnpm 9.x workspaces
BackendHono + OpenAPI (@hono/zod-openapi)
FrontendNext.js 16 App Router + React 19
DatabasePostgreSQL via Drizzle ORM
AuthSupabase Auth (JWT + service accounts)
TestingVitest + Playwright
Linting/FormattingESLint 9 + typescript-eslint + Prettier

Project structure

client-os/
apps/
api/ Hono OpenAPIHono -- /health, /api/me, auth+rate-limit middleware, org-scoped DB
web/ Next.js App Router -- login + authed shell + stub landing/dashboard
cli/ Commander `client` -- health command stub + SEA/npm build pipelines
desktop/ Tauri 2.x shell -- placeholder frontend
e2e/ Playwright -- health + auth smoke tests
packages/
auth/ JWT + PAT verify
auth-store/ Zustand auth state
config/ env preload
core/ shared types/enums
db/ Drizzle client + identity migrations
environment/ env var schema
eslint-config/ shared lint
fetch-client/ HTTP client wrapper
react-query/ TanStack Query provider
object-store/ S3/Supabase Storage wrapper
telemetry/ event schemas + redaction
api-client/ kubb-generated types/hooks

Each package exposes "." via its exports field with types + default, and inter-package references use workspace:*. Every package builds via its own tsc --build; the root pnpm typecheck builds the whole project graph.

Request flow

  1. apps/web calls the API through @client/fetch-client and @client/react-query, using types generated by kubb into packages/api-client from the API's own OpenAPI spec — the browser/RSC layer never queries Supabase directly.
  2. apps/api (Hono + OpenAPIHono) validates the request against its Zod schemas, verifies the caller's Supabase-issued JWT, and resolves org membership/role via a DB lookup.
  3. Data access goes through Drizzle ORM against Postgres.

The clientos CRM domain

client-os ships a working, non-trivial domain — a personal consulting CRM — to demonstrate the full pattern end to end, not just a todo-list demo.

  • Schema: Postgres schema clientos, 39 tables (clients, contacts, projects, tasks, promises, invoices, companies, deployments, servers, domains, contracts, retainers, time_entries, and more) — see packages/db/src/schema-clientos.ts. There's also an empty resume schema (schema-resume.ts) reserved for a separate, not-yet-built migration.
  • Org scoping: every table carries org_id. Unlike a typical Supabase app, this is not enforced by Postgres Row-Level Security — the connection model this repo uses doesn't support RLS policies keyed off the caller's JWT. Instead, org scoping is enforced entirely at the application layer: every route in apps/api/src/routes/clientos/* resolves the caller's org memberships (callerOrgIds()) and filters queries against that list before returning or mutating data. If you add a new clientos table, the org-scoping check belongs in your route handler, not in a database policy.
  • API: apps/api/src/routes/clientos/* — 14 route files covering ~37 resources, mounted under /api/clientos/*. Role gating follows a three-tier model: org:viewer+ can read, org:contributor+ can write, org:admin can delete.
  • Web: apps/web/src/app/dashboard/{clients,projects,money,tech,timeline} — CRM pages living under the existing dashboard shell/nav, calling the API above through @client/fetch-client/@client/react-query.
  • CLI: apps/cli/src/commands/clientos/* — 16 commands (invoked as client clientos <cmd>) that talk directly to Supabase using the service-role key. This is a deliberate exception to the "always go through the API" rule above: the CLI is a trusted local operator tool, not a client-facing surface, so it doesn't need the API's auth/org-scoping layer in front of it.

Data note: only a demo seed (acme-corp) ships with the repo. Real CRM data entered before this scaffold's migration was lost along with an ephemeral local Postgres instance — expected, not a bug you'll hit in your own usage.