# Standards > Cross-product architecture and styling standards. Index: https://docs.michaelchen.me/llms.txt # Bootstrapping a Product > The step-by-step skeleton sequence for starting a product on the standard, and how to verify the serving model. Source: https://docs.michaelchen.me/guides/bootstrap/ · Markdown: https://docs.michaelchen.me/guides/bootstrap/index.md # Bootstrapping a Product The assembly sequence for a new product conforming to the [architecture standard](/standards/architecture/). Until the template repo exists, this is a hand-assembled checklist; the invariants in the standard's §13 are the acceptance test. ## The skeleton, in order 1. **Root scaffold:** `package.json` (the `marketing` workspace + the build pipeline scripts), `vite.config.ts`, `wrangler.jsonc`, both tsconfigs, `app.html`, `scripts/merge-marketing.mjs`. 2. **Cross-surface boundaries:** top-level `config/` (`brand.ts`, `routes.ts` — pure typed data) and `styles/` (`tokens.css`), with the `#config/*` subpath import declared in `package.json` `"imports"` and mirrored in all three tsconfigs **and** `vite.config.ts`. 3. **`src/` three-folder split:** `client/` (main.tsx, App.tsx, api.ts, i18n.tsx, pages/, components/ui + features, lib/), `worker/` (index.ts, routes/ with middleware.ts, services/, db/), `shared/` (types.ts + domain helpers). 4. **The serving contract:** the Worker catch-all using `isAppPath()` from `config/routes.ts`, `app.html` as the SPA shell, no `not_found_handling`, marketing owning `index.html`. 5. **`marketing/`** Astro workspace importing `styles/tokens.css` and `#config/*`; static output; built-in i18n with an unprefixed default locale. 6. **`migrations/`, `test/`, `docs/`** conventions; run `wrangler types` and wire `npm run check`. Backend-internal conventions — auth shape, testing bar, env validation — are a later phase (standard §10–§12); copy the reference implementation as a starting point. What changes per product: the domain and `wrangler` name, the `Env` bindings and DB name, the route modules and service/db domains, the `config/` values and token palette, and the marketing content. The *shape* stays identical. ## Verifying the serving model Run this after assembly and **after any change to the serving model** — there are no route-level tests for it, so the check is manual and cheap. ```sh npm run build && npm run preview ``` Then assert each path returns the expected source and status: ``` / 200 (marketing) /
200 (shell) /pricing/ 200 (marketing) /
/ 200 (shell) /admin/ 200 (shell) // 200 (shell) / 404 (marketing) /garbage 404 (marketing) /api/nope 404 (JSON) ``` The failure modes this catches: a SPA shell served with 200 for garbage URLs (missing catch-all logic), marketing pages falling through to the shell (asset merge broke), or app paths 404ing (drift between `config/routes.ts` and the mounted routes — which the `test/routes.test.ts` boundary pins should also catch). Finish with the full gate: ```sh npm run check && npm test ``` # Tenant Scoping Wiring > How the db/ chokepoint, scope middleware, and route-family taxonomy fit together in the Worker. Source: https://docs.michaelchen.me/guides/tenant-scoping/ · Markdown: https://docs.michaelchen.me/guides/tenant-scoping/index.md # Tenant Scoping Wiring How to implement §4/§10 of the [architecture standard](/standards/architecture/): one shared D1 database, a `tenant_id` column on every tenant-owned table, and a per-request scoped accessor that makes an unscoped — or wrongly-scoped — query unrepresentable in route and service code. ## The model One shared database. Tenancy is a **filter, not a database boundary**: every tenant-owned table carries `tenant_id`, and every query includes `WHERE tenant_id = ?`. The accessor exists so that filter is baked in rather than remembered. Name the accessor `repo` in code, never `db` — it wraps the shared database; it is not a database handle, and nothing in this pattern is database-per-tenant. ## The accessor factories (`db/scope.ts`) ```ts // db/scope.ts — constructed once per request; tenantId from a verified credential export function forTenant(db: D1Database, tenantId: string) { return { listInvoices: () => db.prepare("SELECT … FROM invoices WHERE tenant_id = ?1") .bind(tenantId).all(), // every method closes over tenantId — it is never a parameter }; } export type TenantRepo = ReturnType; ``` Because the tenant id is closed over at construction and never appears as a parameter, the classic multi-tenant bug — filtering by a client-supplied workspace id — has no place to happen south of the middleware. Two companion factories: - **`db/global.ts`** — explicitly-unscoped queries for tenant-free tables: user lookup by email, session create/validate, password-reset tokens, global config. The separate, loudly-named home is what keeps "unscoped" a visible decision instead of a quiet default. - **`forTenantAsStaff(db, staff, tenantId)`** — the one sanctioned "id from user input" case: a **verified staff principal** picks a workspace to operate on. The authorization is the staff credential, not the id. The distinct name means `grep forTenantAsStaff` lists every cross-tenant access path in the product — exactly the list you want auditable. ## Construction site: scope middleware (`routes/middleware.ts`) Accessors are constructed **once per request, in middleware**, and handed down via Hono's typed context. This file is the only place outside `db/` that may touch the D1 binding. ```ts // routes/middleware.ts type Authed = { Bindings: Env; Variables: { session: Session; repo: TenantRepo } }; export const requireAuth = createMiddleware(async (c, next) => { const session = await validateSession(c); // 401s on failure c.set("session", session); c.set("repo", forTenant(c.env.DB, session.tenantId)); // sole construction site await next(); }); export const publicScope = createMiddleware(async (c, next) => { c.set("repo", globalRepo(c.env.DB)); // unscoped, deliberately await next(); }); ``` Routes read `c.var.repo` and pass it to services; services take `(repo, args)` and never see the raw binding: ```ts // routes/admin.ts admin.get("/invoices", async (c) => { const invoices = await listOverdue(c.var.repo, c.req.query()); return c.json(invoices); }); ``` Because the accessor lives in the context *type*, a route family without a scope middleware has no `repo` at all — a public route reaching for tenant-scoped methods is a compile error, not a code-review catch. ## The scope taxonomy Each route family declares its scope once, at the mount point in `index.ts`. Not every family resolves a tenant — that's the taxonomy, not a workaround: | Route family | Middleware | Scope injected | |---|---|---| | signin / signup / reset | `publicScope` | `globalRepo` — pre-tenant by nature; signin *mints* the credential and resolves the tenant into the session | | authenticated app | `requireAuth` | `forTenant` — tenant id from the session | | webhooks / integrations | per-integration verify | `forTenant` — tenant id resolved from the verified signature/token | | system admin | `requireStaff` | `forTenantAsStaff` — verified staff principal picks the workspace | | cron / `scheduled()` | none (no request) | constructs `forTenant` per tenant in its loop | Notes per family: - **Signin** queries by email through `globalRepo`, verifies the password, looks up the user's workspace membership — that lookup is where the tenant is *resolved* — and writes the session with `tenantId` in it. Every later request gets its scope from that session via `requireAuth`. - **Multi-workspace users**: the session carries the active workspace; switching workspaces rewrites the session. "Tenant comes from the session" stays true. - **Webhooks** have no cookie; they verify the signature, map the verified token to a tenant, then construct `forTenant` — same invariant, different credential. - **Cron** has no request context; `forTenant` is a plain function, so the scheduled handler constructs one per tenant as it iterates. ## What conformance checks From the standard's §13: - **Grep:** the D1 binding (`env.DB`, `.prepare(`) appears only under `src/worker/db/` and in `routes/middleware.ts`. - **Grep:** `forTenantAsStaff` call sites are the complete cross-tenant access list — audit it in review. - **Review fact (not mechanizable):** each scope middleware constructs its accessor from the credential it just verified. A route that passes any client-supplied id into `forTenant()` defeats the chokepoint. # Product Architecture Standard > One Worker, one deploy, one domain — the canonical repo structure every product on this stack starts from. Source: https://docs.michaelchen.me/standards/architecture/ · Markdown: https://docs.michaelchen.me/standards/architecture/index.md # Product Architecture Standard A reference for how these products are structured: **one Cloudflare Worker, one deploy, one domain — serving three surfaces** (a React SPA, a JSON API, and a static marketing/docs site) that share a typed product config, one set of design tokens, and an internal app contract. **Standard version: 1.6** — changelog in §14. This document is **normative**: it fixes the stack, the top-level layout, how the three surfaces share code, and how one Worker serves and builds them. Product repos do **not** copy it — they record which version they conform to and pin it (§14). Implementation walkthroughs live in the guides: [tenant scoping](/guides/tenant-scoping/) and [bootstrapping a product](/guides/bootstrap/). **Scope: repository structure.** The Worker's *internal* behavioral conventions — error handling, testing depth, env validation, auth/tenancy beyond the structural rules below — vary across products and are a later phase (§10–§12). --- ## 1. Principles 1. **One Worker, one deploy, one origin.** No CORS, no cross-service auth, no URL migration between "the app" and "the site." Everything ships in a single `wrangler deploy`. Static assets are served by the Workers asset pipeline; only `/api/*` and unmatched fallbacks invoke Worker code. 2. **Shared by scope, not by convenience.** Three things cross module boundaries, and each lives where its *consumers* are: - **`config/`** (top level) — typed product facts, consumed by all three surfaces. - **`styles/`** (top level) — design tokens (CSS), consumed by the SPA and marketing. - **`src/shared/`** — the app's internal type contract, consumed only by the SPA and Worker. The rule: if a value could appear on the pricing page it's `config/`; if it only describes the API/domain it's `src/shared/`. See §5. 3. **Static by default, dynamic only where needed.** Marketing, pricing, legal, blog, and docs are pre-built static HTML (Astro). The SPA is a static bundle. The Worker is invoked only for the API and for serving the right HTML shell on a fallback path. 4. **Two isolated TypeScript projects for the app.** Client code (DOM libs) and Worker code (workerd types) have incompatible global environments. They are compiled by separate `tsconfig`s that share only `src/shared`. 5. **The Worker splits HTTP from logic from data access.** Route files (`routes/`) do HTTP; business logic lives in `services/` as plain functions; data access lives in `db/` behind per-request scoped accessors constructed by each route family's middleware (§4). --- ## 2. Top-level repo layout ``` / ├── config/ # cross-surface product FACTS (typed data) │ ├── brand.ts # product name, site origin, support email │ └── routes.ts # app route paths + isAppPath() — the single │ # declaration the Worker, SPA, and marketing │ # site all read (see §5) ├── styles/ # cross-surface design tokens (CSS) │ └── tokens.css # custom properties + Tailwind @theme ├── src/ # the app: SPA + Worker + internal contract │ ├── client/ # React SPA (browser) │ ├── worker/ # Cloudflare Worker (API + serving) │ └── shared/ # app-internal types both app halves import ├── marketing/ # separate Astro project (static site + docs) │ └── public/ # second asset root — see rule below ├── migrations/ # D1 SQL migrations, numbered 0001_*.sql … ├── scripts/ # build/ops scripts (merge-marketing, seeds, …) ├── test/ # vitest suites (workers pool) ├── public/ # app static files — reserved prefix only ├── docs/ # product-local design specs and plans │ ├── app.html # SPA HTML shell (NOT index.html — see §7) ├── seed.sql # D1 seed data (npm run db:seed) ├── vite.config.ts # builds the SPA + bundles the Worker ├── wrangler.jsonc # Worker + bindings (D1, DO, AI, assets, vars) ├── worker-configuration.d.ts # generated by `wrangler types` — never hand-edited ├── tsconfig.json # client project (DOM libs) ├── tsconfig.worker.json # worker project (generated runtime types, no DOM) ├── components.json # shadcn generator config ├── package.json # root; declares `marketing` as a workspace └── README.md ``` **Two `public/` directories land in the same deployed asset root.** Keep them disjoint **by construction**: the app's `public/` may only contain files under a reserved prefix (`/embed/*`, `/widget/*` — the product's embed surface); marketing owns the root namespace (favicon, fonts, generated robots/sitemap). The merge guard in §8 is the backstop, not the rule. The mental model: **`src/` is the application, `marketing/` is the website**, and they meet only in the merged build output, the shared tokens (`styles/`), and the shared product config (`config/`). --- ## 3. Surface A — the React SPA (`src/client`) The authenticated app and all dynamic guest pages. A static SPA bundle; the Worker never renders React. ``` src/client/ ├── main.tsx # entry: mounts providers (Router, i18n, Toast) ├── App.tsx # — paths come from config/routes.ts ├── api.ts # thin typed API client (one `call()` helper) ├── i18n.tsx # i18n provider + typed message dictionary ├── realtime.ts # WebSocket client for live updates (if used) ├── styles.css # imports shared tokens, then app styles ├── pages/ # one folder/file per route ├── components/ │ ├── ui/ # shadcn primitives — the generator default │ ├── .tsx # composed feature components │ └── skeletons.tsx └── lib/ # cn(), small helpers (shadcn convention) ``` **Rules** - `App.tsx` composes the routes; the *paths* come from `config/routes.ts` so the Worker's shell-vs-404 decision (§7) and marketing's CTAs can't drift. - `api.ts` is the **only** place `fetch` talks to the API: one `call()` wrapper that sets credentials, parses JSON, and throws a typed `ApiFailure(status, code)` on non-2xx. Request/response types come from `src/shared`, never redefined. - `components/ui/` keeps the shadcn generator default (`components.json` ships `ui: "@/components/ui"`) — do not rename it. Edits to its files stay theme/behavior-level; feature composition lives in feature components. No sibling `ui.tsx` next to the `ui/` directory. - User-facing text goes through `i18n.tsx`; the dictionary is a typed object so a missing key is a compile error. --- ## 4. Surface B — the Worker (`src/worker`) `/api/*` handlers, the cron entry, Durable Objects, and the HTML-shell fallback. Hono for routing; plain functions for logic. ``` src/worker/ ├── index.ts # entry: composes routes, asset fallback, │ # onError, scheduled() cron, DO re-exports ├── realtime.ts # Durable Object class (e.g. a per-tenant hub) ├── routes/ # HTTP layer — one Hono sub-app per area │ ├── middleware.ts # scope-injecting middlewares (requireAuth, …) │ ├── public.ts # guest API │ ├── admin.ts # authenticated API │ └── .ts # webhooks, OAuth callbacks ├── services/ # business logic — plain functions (repo, args) │ ├── auth.ts # session/authorization │ ├── .ts # one module per domain area │ └── util.ts # ApiError + tiny shared primitives — keep small └── db/ # data access — the tenant chokepoint ├── scope.ts # forTenant() + forTenantAsStaff() factories ├── global.ts # explicitly-unscoped queries (sessions, config) └── .ts # tenant-scoped queries (via scope.ts factories) ``` **The entry file (`index.ts`) is the spine.** It creates one `Hono<{ Bindings: Env }>` app, mounts each route module (most specific prefix first), defines the catch-all that serves the correct HTML shell (§7), registers one central `onError`, exports `{ fetch, scheduled }` (cron work wrapped in `ctx.waitUntil`), and re-exports Durable Object classes. **Structural rules:** - HTTP in `routes/`, business logic in `services/`, data access in `db/`. There is no separate handler layer (a Hono sub-app *is* route + handler) and no mandated repository stack beyond `db/`. - **`db/` is the tenant-isolation chokepoint.** One shared D1 database, a `tenant_id` column on every tenant-owned table, and every query issued through a per-request scoped accessor whose tenant key is closed over from a **verified credential** — never passed as a parameter below the middleware that constructs it. In code the accessor is named `repo`, not `db`: it wraps the one shared database; nothing here is database-per-tenant. - **Scope accessors are constructed only in `routes/middleware.ts`** — the one file outside `db/` that may touch the D1 binding. Each route family is injected with the scope it is entitled to (global / tenant / staff); cross-tenant access goes only through the distinctly-named `forTenantAsStaff()`. Wiring, code, and the scope taxonomy: [tenant scoping guide](/guides/tenant-scoping/). - A genuinely single-tenant product may relax `db/` to a growth path (inline SQL in services until queries crowd out logic); workspace-scoped is the default on this stack. - **`Env` is generated, never hand-maintained.** `wrangler types` emits `worker-configuration.d.ts` from `wrangler.jsonc` + `.dev.vars`, run first by `npm run check` (§8). Nothing reads an untyped `env.SOMETHING`. > Behavioral conventions (error mapping, module-size limits, auth shape) are a > later phase — see §10–§12. --- ## 5. The shared boundaries — three scopes, three homes | Home | Holds | SPA | Worker | Marketing | |------|-------|:---:|:---:|:---:| | **`config/`** (top level) | typed product facts | ✅ | ✅ | ✅ | | **`styles/`** (top level) | design tokens (CSS) | ✅ | — | ✅ | | **`src/shared/`** | app-internal type contract | ✅ | ✅ | — | ### `config/` — product facts (all three surfaces) "Facts about the product a customer could see," in the one place all three surfaces can read: `brand.ts` (name, origin, support address) and `routes.ts` (app route paths + `isAppPath()`, pinned by `test/routes.test.ts`). - Everything in `config/` is **pure data + types with zero runtime imports**. - It resolves via a real Node subpath import — `"imports": { "#config/*": "./config/*.ts" }` in root `package.json` — mirrored in all three tsconfigs and `vite.config.ts` (§9). - **Nothing enters `config/` until it has two real consumers.** One consumer is a local constant. (Per-tier limits read by both the pricing page and the Worker are the canonical future case — add them when tiers exist.) ### `styles/` — design tokens (SPA + marketing) `tokens.css` is the visual contract, structured in layers, and the ordering is the rule: 1. **Brand palette** — the raw scale, and the **only place a hex literal appears**. 2. **shadcn base** — the ~12 semantic roles (`--color-background`, `--color-primary`, `--color-muted`, …) mapped onto the palette via `var()`, never a re-typed hex. **Role names are fixed across every product; only the values differ** — that's what lets components port. 3. **Brand extensions** — extra roles a product needs (e.g. `--color-success`), added here on the same palette — never a parallel system. **Dark mode lives in the base from day one** — a `.dark {}` block overriding role assignments (not the palette). If the site uses a vendored/themed template with its own token system, decide explicitly: remap its tokens onto yours (`--nb-bg: var(--color-background)`) or accept two systems and document the boundary. Silently having both is the option that rots. ### `src/shared/` — the app's internal contract (SPA + Worker) Request/response shapes, shared enums, and pure domain helpers both app halves use. No runtime dependencies; the only directory compiled by both app tsconfigs. Marketing never imports it. The line against `config/`: this describes the **API/domain**, not customer-facing product facts. ### Graduating to a workspace package Top-level dirs resolved via aliases are right for one product. When a second product needs to share either across repos, promote them to workspace packages (`@product/config`, `@product/tokens`) — contents unchanged, only the declaration moves. --- ## 6. Surface C — the marketing site (`marketing/`) A **separate Astro project**, its own npm workspace, building all static non-app pages: landing, pricing, legal, blog, docs. Isolated so its dependency churn can't destabilize the app. ``` marketing/ ├── astro.config.ts # static output, docs integration, │ # trailingSlash, prefetch, built-in i18n ├── package.json # its own deps (workspace member) ├── tsconfig.json # own ~/* alias + the shared #config/* alias ├── public/ # second asset root — root namespace (see §2) ├── src/ │ ├── pages/ # routes + generated endpoints (og, llms.txt, │ │ # robots, sitemap); en/ locale variants │ ├── content/ # content collections (blog/, docs/) │ ├── layouts/ # page shells (Marketing vs Docs) │ ├── components/ # Astro components (+ a ui/ kit) │ ├── lib/ # i18n dictionary + helpers │ └── styles/ # imports ../../../styles/tokens.css + site CSS └── AGENT.md # authoring rules for this sub-project ``` **Rules** - **Static output.** No SSR — every page is a file at build time. - **Bilingual routing uses Astro's built-in i18n** (`prefixDefaultLocale: false`): unprefixed default locale, secondary under `/en/*`, `getRelativeLocaleUrl()` for URL generation. The framework does *not* write hreflang: pages that don't exist in both locales must pass their real alternates or they emit hreflang links to 404s. - **Trailing slashes are load-bearing.** Directory-format output serves pages at `/pricing/`; `trailingSlash: "always"` plus slash-normalizing URL helpers keep canonical, hreflang, sitemap, and internal links on one form. - **Shared *values* go in `config/`; shared *prose* does not.** The SPA and marketing keep separate i18n dictionaries by design — marketing copy and product copy diverge. - `AGENT.md` documents authoring rules and must stay honest about the real deploy shape: this workspace merges into the app's asset output; it does not deploy standalone. --- ## 7. Serving model — how one Worker serves three surfaces **Build produces one asset directory**: the marketing site's static pages (owning `index.html` at `/`), the SPA bundle (shell renamed to `app.html`), and all hashed assets. **At runtime, requests resolve in this order:** 1. `run_worker_first: ["/api/*"]` sends API paths straight to the Worker. 2. Everything else hits the **static asset pipeline first** — real files are served with no Worker invocation. 3. **Unmatched paths fall through to the Worker's catch-all.** There is deliberately **no `not_found_handling`** in `wrangler.jsonc` — that's what makes misses invoke the Worker instead of blindly serving a SPA shell. 4. The catch-all calls `isAppPath()` from `config/routes.ts` — the same module the SPA and marketing read. Match → serve `app.html` with **200**. No match → serve the marketing **`404.html` with 404**. Why `app.html`: marketing owns `index.html` at the root, so the SPA shell builds under a different name (`vite.config.ts` sets `build.rollupOptions.input` to `app.html`) and the Worker hands it out only for real app routes. The payoff: garbage URLs get a real 404, marketing owns `/`, and the catch-all is the hook point for future per-route SSR touches. **Verify by hand after any change to the serving model** — expected statuses and steps in the [bootstrap guide](/guides/bootstrap/). --- ## 8. Build & deploy pipeline Driven entirely by root `package.json` scripts. Order matters. ``` npm run build 1. vite build → SPA bundle + Worker, into dist/client 2. npm run build -w marketing → Astro static site, into marketing/dist 3. node scripts/merge-marketing.mjs → copies marketing/dist into dist/client; HARD-FAILS on any filename collision npm run deploy = build + wrangler deploy ``` - **`npm run check`** — `wrangler types`, then type-checks all three projects. - **`npm run dev`** — the app (Vite + Worker via `@cloudflare/vite-plugin`); **`dev:marketing`** — Astro alone; **`preview`** (after a build) — all three together, exactly as production serves them. - **`test/`** — vitest + `@cloudflare/vitest-pool-workers`: worker code runs in workerd against isolated real bindings; `#config/*` resolves through `vite.config.ts`. - **`migrations/`** — numbered SQL applied with `wrangler d1 migrations apply` (local + `--remote` scripts). --- ## 9. Config files — what each one owns | File | Owns | |------|------| | `wrangler.jsonc` | Worker name, compat date, **bindings**, asset config (`run_worker_first`, no `not_found_handling`), non-secret `vars`. | | `vite.config.ts` | SPA build + Worker bundling, the `app.html` input rename, bundle-time aliases (`@/*` → `src/client`, `#config` → `config/`). | | `tsconfig.json` | **Client** project: DOM libs, `jsx`, `@/*`, `#config/*`; includes `src/client`, `src/shared`, `config`. | | `tsconfig.worker.json` | **Worker** project: generated runtime types, no DOM, `#config/*`; includes `src/worker`, `src/shared`, `config`. | | `package.json` | Root deps + scripts; the `marketing` workspace; the canonical `#config/*` subpath import (`"imports"`). | | `marketing/tsconfig.json` | `~/*` → `marketing/src/*` plus the shared `#config/*` → `../config/*`. | | `components.json` | shadcn generator config for the SPA primitives. | **Alias rules:** - **`@/*` belongs to the app alone** (`src/client/*`) — shadcn-generated code hard-imports it. Marketing uses **`~/*`** (`marketing/src/*`). The two projects never share a sigil. - **`#config/*` is a real Node subpath import**, declared canonically in root `package.json` `"imports"` with an explicit `.ts` (Node does no extension guessing), mirrored in the three tsconfigs (typecheck) and `vite.config.ts` `resolve.alias` (bundles). All declarations are exercised by `npm run check` + `npm run build`. --- ## 10. Auth, sessions, and tenant isolation — later phase > **Not standardized yet** beyond the structural rules in §4. Two security > invariants hold wherever tenancy applies: **the tenant key comes from a > verified credential** — the session for app routes, the verified > signature/token for webhooks — **never from client input**; and it enters > queries only through the `db/` scoped accessor constructed in scope > middleware. Cross-tenant access goes only through `forTenantAsStaff()`. > See the [tenant scoping guide](/guides/tenant-scoping/). --- ## 11. Testing contract — later phase > **Not standardized yet.** The fixed part is structural: tests live in > `test/` and run with vitest + `@cloudflare/vitest-pool-workers` (§8). What > must be tested, and to what bar, settles with the backend conventions. --- ## 12. Environment, secrets, and validation — later phase > **Mostly not standardized yet.** The structural part: public constants in > `config/` (§5), per-environment values in `wrangler.jsonc` `vars`, secrets > via `wrangler secret put` / `.dev.vars` (§9). One hard rule: **never put a > secret in `vars`** — a plaintext `var` overwrites the real secret with `""` > on the next deploy. --- ## 13. Conformance **Invariants — this list is the conformance test:** - One deploy, one origin — never split the API onto a second service. - Shared things live by consumer: `config/` (all three surfaces), `styles/` (app + marketing), `src/shared/` (app-internal only). No surface redefines another's shapes. - Nothing enters `config/` until it has **two real consumers**. - The Worker splits HTTP (`routes/`) from logic (`services/`) from data access (`db/`). - The D1 binding is touched only under `db/` and `routes/middleware.ts`; services take the scoped accessor injected by their route family's middleware; its tenant key comes from a verified credential (§4, §10). - `Env` comes from `wrangler types`, never a hand-maintained interface. - `api.ts` is the sole client↔API boundary. - App route paths are declared once in `config/routes.ts` — never hand-mirrored. - The marketing build merges into the app's assets with a collision guard; marketing owns `/`; the SPA shell is `app.html`; app `public/` stays under its reserved prefix. - `npm run check` and `npm test` pass. **Mechanizable checks** (run in the reference repo; part of the template when it exists): | Invariant | Check | |---|---| | Shared things live by consumer | import-boundary lint: `marketing/**` never imports `src/shared`; `src/worker/**` never imports DOM globals | | `config/` two-consumer rule | count importing files per export across surfaces; fail under 2 | | `api.ts` sole boundary | grep for `fetch(` under `src/client` outside `api.ts` | | Route paths declared once | grep for hardcoded app-path literals outside `config/routes.ts` | | Data access only via `db/` | grep for the D1 binding (`env.DB`, `.prepare(`) outside `src/worker/db/` + `routes/middleware.ts` | | Serving model (§7) | scripted status assertions — table in the [bootstrap guide](/guides/bootstrap/) | What changes per product: the domain and `wrangler` name, the `Env` bindings and DB name, the route modules and service/db domains, the `config/` values and token palette, and the marketing content. The *shape* stays identical. The step-by-step skeleton sequence: [bootstrap guide](/guides/bootstrap/). --- ## 14. Versioning This document is the single source of truth, versioned here (`Standard version: 1.x` at top). A product repo records which version it conforms to (a line in its README or a `standard-version` field) and pins it. When the standard changes in a way existing products should adopt, the changelog entry says *what* and *whether it's retroactive*; conforming products bump their pin deliberately. | | Where it lives | Why | |---|---|---| | This standard | **here, canonical** | products reference a version, never fork the prose | | Skeleton (layout, configs, serving model) | **template repo** (future) | copied once at birth | | `styles/tokens.css` | **copy the contract, not the values** | role names identical; palette per-brand | | `config/*` | **copy** | values product-specific; only the shape is shared | **Reference implementation status.** The production repo this standard was extracted from predates several v1.4–1.5 conventions (`components/ui`, `services/`, the `db/` chokepoint, generated `Env`, vitest, `package.json` imports, `~/*`, built-in i18n). Migrating it is the open conformance task. **Changelog** - **1.6** — Restructured to normative-only: implementation how-tos moved to the guides; rationale essays and roadmap material removed. No rule changes. - **1.5** — `db/` required by default as the tenant-isolation chokepoint: per-request scoped accessors constructed in scope middleware (global/tenant/staff taxonomy, `forTenantAsStaff()` for sanctioned cross-tenant access). Single-tenant products may relax to a growth path. - **1.4** — Realigned onto ecosystem defaults: `components/ui/`, worker `services/`, generated `Env` via `wrangler types`, `#config/*` as a real subpath import, `~/*` for marketing, vitest workers pool, app `public/` prefix rule, Astro built-in i18n. - **1.3** — Extracted to a canonical single-source standard; examples genericized; base-token contract added to §5. - **1.2** — Scope narrowed to repository structure; worker internals became later-phase stubs (§10–§12). - **1.1** — Split cross-surface sharing into `config/` + `styles/`; route paths moved to `config/routes.ts`. - **1.0** — Initial: one-Worker three-surface layout, serving model, build pipeline. # Styling Standard > Shared design tokens, typography, and component conventions across products. Source: https://docs.michaelchen.me/standards/styling/ · Markdown: https://docs.michaelchen.me/standards/styling/index.md # Styling Standard This standard captures the design tokens, typography scale, and component conventions shared across products on this stack. It is being extracted from the reference implementations (`michael-chen`, `tabler`) and will grow section by section; for now it establishes the canonical location that product repos reference. ## Scope - Design tokens (color, spacing, radius, typography) as the shared source of truth. - Component conventions that keep surfaces consistent across products. Until a section below is filled in, treat the reference implementations' tokens as authoritative.