# Acme Inc. > A full-stack starter with TanStack Start and Expo on one Cloudflare Worker. Auth, R2 files, Postgres, payments, AI and MCP tools, and a preview per PR. Streaming chat with TanStack AI and shared MCP tools. Off until a provider is configured and the assistant flag is enabled. These documents describe the template. Public documentation contains no account or organization data. Authenticated tools apply the same organization permissions as the app. ## Public pages - [Developer docs](https://template-pr-137.its-live.dev/docs): Setup, architecture and development guides. - [Changelog](https://template-pr-137.its-live.dev/docs/changelog): Changes to the template. - [Overview](https://template-pr-137.its-live.dev/index.md): Features, architecture, setup and FAQ from the landing page. - [Privacy policy](https://template-pr-137.its-live.dev/privacy.md): The template's privacy text and replacement notice. - [Terms of service](https://template-pr-137.its-live.dev/terms.md): The template's terms and replacement notice. - [Full documentation](https://template-pr-137.its-live.dev/llms-full.txt): Public pages, demo content and the agent connection guide. ## Developer docs Developer documentation is available in English only. UI language does not translate these documents. - [Architectural decisions](https://template-pr-137.its-live.dev/docs/adr.md): Read the original decisions, their constraints and their recorded evidence. - [Backend shape](https://template-pr-137.its-live.dev/docs/architecture/backend-shape.md): Why every backend feature has the same files, why there is no barrel and no repository layer, and where Effect runs. - [Client/server boundary](https://template-pr-137.its-live.dev/docs/architecture/client-server-boundary.md): Keep server capabilities out of browser and native module graphs. - [Effect at the edges](https://template-pr-137.its-live.dev/docs/architecture/effect.md): Use stable Effect APIs for owned server work without changing public contracts. - [Architecture](https://template-pr-137.its-live.dev/docs/architecture.md): One Worker owns web rendering, API requests and scheduled execution. - [Observability and tracing](https://template-pr-137.its-live.dev/docs/architecture/observability.md): Read structured errors, durable audit records and Cloudflare spans as separate signals. - [Request lifecycle](https://template-pr-137.its-live.dev/docs/architecture/request-lifecycle.md): Follow authority and database lifetime from the incoming Request to the response. - [Changelog](https://template-pr-137.its-live.dev/docs/changelog.md): New features, architectural changes and improvements to the template. - [Deployment and rollback](https://template-pr-137.its-live.dev/docs/deploy.md): GitHub Actions deploy isolated previews and the production Worker with reviewed migrations. - [Backend conventions](https://template-pr-137.its-live.dev/docs/development/backend-conventions.md): The rules for code in packages/api, each with its reason, an example, the anti-pattern and what enforces it. - [Conventions](https://template-pr-137.its-live.dev/docs/development/conventions.md): Use the existing owners and enforce boundaries in code and tooling. - [Writing documentation](https://template-pr-137.its-live.dev/docs/development/docs.md): Author repository MDX, reuse architectural decisions and curate release notes. - [Enforcement](https://template-pr-137.its-live.dev/docs/development/enforcement.md): Which rule is checked by what: type checks, lint, tests, build and deploy gates, scripts, and the rules that still rely on review. - [Environment and secrets](https://template-pr-137.its-live.dev/docs/development/environments.md): Keep Node, Worker and native configuration aligned without exposing credentials. - [Review checklist](https://template-pr-137.its-live.dev/docs/development/review-checklist.md): Review or self-review a change against the repository's rules, with the command or test that checks each item. - [Runbooks](https://template-pr-137.its-live.dev/docs/development/runbooks.md): Symptom, command and verification for the operational procedures the template supports. - [Workspace scripts](https://template-pr-137.its-live.dev/docs/development/scripts.md): Run the repository’s existing entry points from the workspace root. - [Local setup](https://template-pr-137.its-live.dev/docs/development/setup.md): Configure a clone without overwriting an existing project’s identity or credentials. - [Testing and verification](https://template-pr-137.its-live.dev/docs/development/testing.md): Verify domain behavior against real Postgres and the deployed runtime shape. - [Administration](https://template-pr-137.its-live.dev/docs/features/admin.md): Current platform authority protects User management, audits, flags and diagnostics. - [Agent-readable pages](https://template-pr-137.its-live.dev/docs/features/agent-pages.md): Public Markdown and discovery derive from the same content developers read. - [oRPC conventions](https://template-pr-137.its-live.dev/docs/features/api.md): Share Zod contracts and typed clients while keeping authorization on the server. - [AI assistant and tools](https://template-pr-137.its-live.dev/docs/features/assistant.md): TanStack AI streams a private conversation through one authorized tool catalog. - [Authentication and Organizations](https://template-pr-137.its-live.dev/docs/features/auth.md): Better Auth owns identity, sessions, Organizations, platform roles and OAuth. - [Database and migrations](https://template-pr-137.its-live.dev/docs/features/database.md): Drizzle manages one Postgres schema through reviewed, journaled SQL migrations. - [Documents](https://template-pr-137.its-live.dev/docs/features/documents.md): Convert supported files to Document Text in the browser, index it from cron and read it through the shared tool catalog. - [Email](https://template-pr-137.its-live.dev/docs/features/email.md): One transport boundary serves verification, recovery, invitations and security notices. - [Feature flags](https://template-pr-137.its-live.dev/docs/features/feature-flags.md): Typed code defaults with database overrides and separate capability gates. - [Files and uploads](https://template-pr-137.its-live.dev/docs/features/files.md): Send files directly to R2, confirm durable upload intent and recover interrupted multipart uploads. - [Internationalization](https://template-pr-137.its-live.dev/docs/features/i18n.md): English, German and French UI with request-scoped SSR translation. - [Features and flows](https://template-pr-137.its-live.dev/docs/features.md): Trace each feature through contracts, authority, persistence and presentation. - [MCP and agent connections](https://template-pr-137.its-live.dev/docs/features/mcp.md): OAuth-authenticated agents use the existing Worker and the shared tool catalog. - [Mobile app](https://template-pr-137.its-live.dev/docs/features/mobile.md): Expo Router uses the shared domain API with native navigation and scoped local state. - [Open Graph images](https://template-pr-137.its-live.dev/docs/features/og-images.md): Takumi renders typed React templates to PNG on Node and workerd. - [Payments](https://template-pr-137.its-live.dev/docs/features/payments.md): Stripe, Polar and RevenueCat feed one server-verified entitlement model. - [Scheduled jobs](https://template-pr-137.its-live.dev/docs/features/scheduled-jobs.md): One cron tick settles six concurrent jobs, then indexes Document Text within the same deadline and scoped pool. - [Todo imports](https://template-pr-137.its-live.dev/docs/features/todo-imports.md): Import an uploaded JSON file with durable claims, version checks and safe replay. - [Webhooks and notifications](https://template-pr-137.its-live.dev/docs/features/webhooks.md): Durable outboxes own delivery and retries across interruptions. - [Developer documentation](https://template-pr-137.its-live.dev/docs.md): Build, extend and operate the Cloudflare web and Expo mobile template. - [Libraries and version policy](https://template-pr-137.its-live.dev/docs/libraries.md): Major dependencies, their declared versions, owners and upgrade constraints. - [ads](https://template-pr-137.its-live.dev/docs/packages/ads.md): Optional native advertising: responsibilities, supported exports and extension points. - [api](https://template-pr-137.its-live.dev/docs/packages/api.md): Authorized operations and shared contracts: responsibilities, supported exports and extension points. - [app-update](https://template-pr-137.its-live.dev/docs/packages/app-update.md): Native update policy: responsibilities, supported exports and extension points. - [auth](https://template-pr-137.its-live.dev/docs/packages/auth.md): Identity and request scope: responsibilities, supported exports and extension points. - [config](https://template-pr-137.its-live.dev/docs/packages/config.md): Tooling configuration: responsibilities, supported exports and extension points. - [db](https://template-pr-137.its-live.dev/docs/packages/db.md): Postgres schema and durable state: responsibilities, supported exports and extension points. - [email](https://template-pr-137.its-live.dev/docs/packages/email.md): Transactional email delivery: responsibilities, supported exports and extension points. - [env](https://template-pr-137.its-live.dev/docs/packages/env.md): Validated environment contracts: responsibilities, supported exports and extension points. - [errors](https://template-pr-137.its-live.dev/docs/packages/errors.md): Stable error vocabulary: responsibilities, supported exports and extension points. - [flags](https://template-pr-137.its-live.dev/docs/packages/flags.md): Feature registry and overrides: responsibilities, supported exports and extension points. - [Packages](https://template-pr-137.its-live.dev/docs/packages.md): The workspace contains 17 packages with explicit ownership and supported import paths. - [intl](https://template-pr-137.its-live.dev/docs/packages/intl.md): Request-scoped language: responsibilities, supported exports and extension points. - [project-setup-cli](https://template-pr-137.its-live.dev/docs/packages/project-setup-cli.md): Project identity and provisioning workflow: responsibilities, supported exports and extension points. - [purchases](https://template-pr-137.its-live.dev/docs/packages/purchases.md): Native purchase coordination: responsibilities, supported exports and extension points. - [react-utils](https://template-pr-137.its-live.dev/docs/packages/react-utils.md): Small shared React helpers: responsibilities, supported exports and extension points. - [storage](https://template-pr-137.its-live.dev/docs/packages/storage.md): Private file gateway: responsibilities, supported exports and extension points. - [ui](https://template-pr-137.its-live.dev/docs/packages/ui.md): Shared web primitives: responsibilities, supported exports and extension points. - [widgets](https://template-pr-137.its-live.dev/docs/packages/widgets.md): Optional native Todo widgets: responsibilities, supported exports and extension points. - [ADR-0001 — Stored file keys keep the user's filename](https://template-pr-137.its-live.dev/docs/adr/0001-stored-file-keys-keep-the-user-s-filename.md): The repository's architectural decision, rationale and consequences. - [ADR-0002 — Server-capable modules have no barrel](https://template-pr-137.its-live.dev/docs/adr/0002-no-barrels-on-server-capable-modules.md): The repository's architectural decision, rationale and consequences. - [Organizations are the tenant](https://template-pr-137.its-live.dev/docs/adr/0003-organizations-are-the-tenant.md): The repository's architectural decision, rationale and consequences. - [Payments: one entitlement read model, three provider modules](https://template-pr-137.its-live.dev/docs/adr/0004-payments-providers.md): The repository's architectural decision, rationale and consequences. - [Effect stays at the server edges](https://template-pr-137.its-live.dev/docs/adr/0005-effect-boundary.md): The repository's architectural decision, rationale and consequences. - [Todo imports and the PersistedQueue gate](https://template-pr-137.its-live.dev/docs/adr/0006-effect-persisted-queue.md): The repository's architectural decision, rationale and consequences. - [AI assistant and one tool catalog](https://template-pr-137.its-live.dev/docs/adr/0007-ai-assistant-and-tool-catalog.md): The repository's architectural decision, rationale and consequences. - [Documents and direct uploads](https://template-pr-137.its-live.dev/docs/adr/0008-documents-and-large-uploads.md): The repository's architectural decision, rationale and consequences. - [Row-level security isolates Organizations](https://template-pr-137.its-live.dev/docs/adr/0009-row-level-security.md): The repository's architectural decision, rationale and consequences. - [Procedures run one Effect program at their edge](https://template-pr-137.its-live.dev/docs/adr/0010-procedures-run-one-effect-program.md): The repository's architectural decision, rationale and consequences. - [Backend features have a fixed file shape](https://template-pr-137.its-live.dev/docs/adr/0011-backend-feature-shape.md): The repository's architectural decision, rationale and consequences. - [Connect your agent](https://template-pr-137.its-live.dev/docs/connect-your-agent.md): Connect an external agent through MCP OAuth and enable the optional assistant. - [Setup, storage and localization](https://template-pr-137.its-live.dev/docs/changelog/2026-06-10-setup-storage-and-i18n.md): The early template gains repeatable configuration, object storage and language support. - [Administration and data workflows](https://template-pr-137.its-live.dev/docs/changelog/2026-07-13-admin-and-data-workflows.md): Admin operations and server-backed tables extend the starter application. - [Native interface foundations](https://template-pr-137.its-live.dev/docs/changelog/2026-07-24-native-foundation.md): Expo screens adopt the native UI direction. - [An enforced client/server boundary](https://template-pr-137.its-live.dev/docs/changelog/2026-07-30-client-boundary.md): Shared constants stop pulling server capabilities into browser bundles. - [Cloudflare email and shared UI](https://template-pr-137.its-live.dev/docs/changelog/2026-08-13-email-and-ui.md): Transactional delivery and the shared interface foundation expand. - [Agent connections and native account security](https://template-pr-137.its-live.dev/docs/changelog/2026-08-24-agent-connections-and-account-security.md): OAuth-backed MCP connections and mobile security flows expand the template. - [Typed domain operations and a real dashboard](https://template-pr-137.its-live.dev/docs/changelog/2026-08-25-typed-domain-and-dashboard.md): Dashboard data, Todo state and administrative writes become more explicit. - [oRPC and Drizzle upgrades](https://template-pr-137.its-live.dev/docs/changelog/2026-09-03-typed-api-upgrade.md): The API and database stack advance with compatibility work across their consumers. - [Platform dependency alignment](https://template-pr-137.its-live.dev/docs/changelog/2026-09-24-platform-dependencies.md): React, Drizzle, files and test tooling move onto verified compatible versions. - [Authentication foundations and performance](https://template-pr-137.its-live.dev/docs/changelog/2026-09-25-auth-and-performance.md): Auth state has clearer ownership on web and mobile, with production build checks. - [Runtime and recovery hardening](https://template-pr-137.its-live.dev/docs/changelog/2026-10-03-runtime-and-recovery.md): Template fixes tighten persisted work and production-runtime behavior. - [Payments, native screens and a new landing page](https://template-pr-137.its-live.dev/docs/changelog/2026-10-12-payments-and-native-experience.md): Optional billing providers, a refreshed native experience and clearer product presentation land together. - [TanStack Start 1.0, OG images and durable imports](https://template-pr-137.its-live.dev/docs/changelog/2026-10-13-start-og-and-imports.md): The web runtime advances, social images render in the Worker, and imports gain bounded queue execution. - [Trace and code block primitives](https://template-pr-137.its-live.dev/docs/changelog/2026-10-14-trace-and-code-blocks.md): Shared shadcn components support readable execution traces and highlighted code. - [AI assistant and one tool catalog](https://template-pr-137.its-live.dev/docs/changelog/2026-10-15-assistant-and-agent-tools.md): The assistant, MCP and agent-readable pages now share the application’s authorized operations. - [Dependency round, i18next fix and ReUI controls](https://template-pr-137.its-live.dev/docs/changelog/2026-10-15-dependency-round-and-reui.md): Eligible packages advance under the two-day quarantine, a reachable i18next ReDoS is patched and six free ReUI controls join the UI package. - [Developer documentation and changelog](https://template-pr-137.its-live.dev/docs/changelog/2026-10-15-developer-docs-and-changelog.md): The Worker now serves searchable developer guides, imported ADRs and a curated changelog at /docs. - [Documents and large uploads](https://template-pr-137.its-live.dev/docs/changelog/2026-10-15-documents-and-large-uploads.md): Files upload directly to R2, including multipart uploads up to 5 GiB, and supported documents become searchable text for the assistant and MCP. - [Landing page redesign](https://template-pr-137.its-live.dev/docs/changelog/2026-10-15-landing-redesign.md): The public landing uses a Taste v2 typographic direction with real interface captures and docs links. - [One folder per backend feature, tagged errors and written rules](https://template-pr-137.its-live.dev/docs/changelog/2026-10-17-backend-structure.md): packages/api moves to features and platform folders with fixed file names, two pilot features run one Effect program per procedure, and every rule now names what enforces it. ## Agent connection - [MCP endpoint](https://template-pr-137.its-live.dev/api/mcp): OAuth 2.1 authenticated MCP; start with tools/list to discover available tools. - [OAuth protected resource](https://template-pr-137.its-live.dev/.well-known/oauth-protected-resource): Resource metadata for MCP authorization. - [OAuth authorization server](https://template-pr-137.its-live.dev/.well-known/oauth-authorization-server): Authorization and token endpoints. - [API reference](https://template-pr-137.its-live.dev/api/rpc/api-reference): Session-authenticated oRPC API documentation. - [OpenAPI specification](https://template-pr-137.its-live.dev/api/rpc/api-reference/spec.json): Session-authenticated API schema. - [Sitemap](https://template-pr-137.its-live.dev/sitemap.xml): Public URLs only. Markdown pages support ?lang=en, ?lang=de and ?lang=fr. This index and the full document are in English. # One Worker. Every client. A full-stack starter for web and mobile. TanStack Start, Expo and one typed API, running on Cloudflare. ## Already running. Todos, files, security and agent connections. Actual screens from the template with example data. [Docs](/docs) · [Changelog](/docs/changelog) ## The working parts. Keep what your product needs. Every integration below is included; optional services stay off until you configure them. ### Product - Authentication (Ready): Email and password, passkeys, two-factor codes, email verification and session management. - Organizations (Ready): Members, invitations and roles. Todos and files belong to the active organization. - Files (Ready): Multipart uploads to R2, previews, share links and a trash. - Admin (Ready): User management, impersonation, feature flags and an audit log. - Todo example (Ready): Grid, board and calendar views, with leased import queues and drafts proposed by AI agents. - Payments (Off by default): Stripe and Polar on the web, RevenueCat on mobile. Off until providers and billing flags are configured. ### Platform - Database (Ready): Drizzle on Neon Postgres, reached through Hyperdrive. - Email (Ready): Transactional email through Cloudflare Email, printed to the console in development. - i18n and SEO (Ready): English, German and French, hreflang, a sitemap and OG images generated with Takumi. - In-app assistant (Off by default): Streaming chat with TanStack AI and shared MCP tools. Off until a provider is configured and the assistant flag is enabled. - Scheduled jobs (Ready): Effect at the edges: cron jobs and outbound delivery, with Postgres leases for queued work. - Cookie consent (Ready): A consent banner that holds back analytics and marketing scripts. ### Mobile - Expo app (Ready): iOS and Android with the same API client, session and passkeys. - Push notifications (In progress): Expo push registration and a delivery queue. Off until credentials are set. - Home-screen widgets (Off by default): Todo widgets for iOS and Android behind a native build option. - Ads (Off by default): AdMob helpers kept as a standalone library, not imported by the app. ## One shared contract. The browser and Expo call the same oRPC router. The Worker checks the session before accessing Postgres or R2. - Web: TanStack Start, React, Tailwind CSS, shadcn/ui - Mobile: Expo, Expo Router, React Native - API: oRPC, Zod, Effect - Auth: Better Auth - Data and files: Drizzle ORM, Neon Postgres, Hyperdrive, R2 - Platform and tooling: Cloudflare Workers, GitHub Actions, Turborepo, pnpm ### apps/web/src/routes/index.tsx ```ts import { useQuery } from "@tanstack/react-query"; import { orpc } from "@/utils/orpc"; const { data: apiOk, isLoading: apiLoading } = useQuery(orpc.healthCheck.queryOptions()); ``` Excerpt from the landing route. The generated client keeps the query typed. ### apps/mobile/src/app/(tabs)/(home)/index.tsx ```ts import { useQuery } from "@tanstack/react-query"; import { orpc } from "@/lib/orpc"; const healthCheck = useQuery( orpc.healthCheck.queryOptions(), ); ``` The Expo home screen calls the same health-check procedure. ### packages/api/src/platform/rpc/app-router.ts ```ts healthCheck: publicProcedure.handler(() => { return "OK"; }), ``` One public health check. Protected procedures additionally require a session. ## Server stays server. Share contracts with your clients. Keep database connections, secrets and server code behind three independent checks. - oxlint: Value imports of server modules fail lint in apps/web, apps/mobile and packages/ui. Type imports are allowed. - Explicit exports: Each package lists its public entry points. No wildcards, so a new cross-package import is a deliberate change. - Client bundle scan: The web build reads the client assets and fails on strings that only exist in server code. ## A preview for every PR. Each pull request gets a preview Worker and a Neon branch. Review the real URL, then merge when it is ready. - Merging ships production: A push to main runs the separate production workflow with production secrets and database. - Cleanup is exact: It checks the project, the pull request and the parent branch, deletes the Worker first and accepts resources that are already gone. ## One tool catalog. TanStack AI and the MCP server use the same authorized tools. Coding agents get the repo instructions and architecture decisions. - AGENTS.md: Rules every coding agent reads first: the client and server boundary, Effect on the server, i18n. - docs/adr: Architecture decisions with their context, so an agent does not undo them. - .agents/skills: Setup, startup, deploy and browser checks as step-by-step skills. - /api/mcp: Agents read your data and propose drafts over OAuth. Drafts wait until you apply them. - /llms.txt: Public Markdown at /index.md and a /llms.txt directory for agents to read before connecting. ## Make it yours. Clone your copy of the repository. Install, configure, and run web and mobile together. ### Clone Start from your copy of the template repository. ```sh git clone my-app ``` ### Install Installs every workspace in the monorepo. ```sh pnpm install ``` ### Configure Names the project and writes env files, Wrangler, database, email and storage settings. ```sh pnpm setup ``` ### Run Web on localhost:3005, plus the Expo dev server for mobile. ```sh pnpm run dev ``` ## Before you clone. ### What can I try in the demo? Create an account and use the real app: todos, files, organizations, passkeys, two-factor and settings. It runs on the same Worker as this page. ### Why Cloudflare Workers? One deploy serves pages, the API, auth and scheduled jobs. Every pull request gets its own Worker, so review happens on a real URL. ### Why Neon Postgres instead of D1? Auth, organizations and the job queues rely on Postgres transactions and locks. Hyperdrive pools the connection, and each pull request gets its own Neon branch. ### Can I remove mobile, payments or ads? Yes. optional-features.json lists each optional package and its entry points, and docs/optional-features.md explains how to remove them. ### How do I rebrand it? Colors, radius and fonts live in one token file. Names and copy live in the locale files. Screens only use tokens, so a palette swap changes every page. ### Do I need to know Effect? Only to change server jobs. Effect coordinates execution in the Worker; contracts use Zod, requests use oRPC, auth uses Better Auth and queries use Drizzle. ### How do I connect an AI agent? Point any MCP client at /api/mcp. You approve access over OAuth, read and write are separate scopes, and writes only create drafts you confirm in the app. ### Does the template include an AI assistant? Yes. The assistant and MCP share one tool catalog, scoped to your organization. Writes require approval. Configure Anthropic, OpenAI or Gemini and enable the assistant flag to show the chat. It is off by default. # Privacy Policy Last updated October 12, 2026 > Template text: This page ships with the template. Replace the bracketed placeholders, adapt it to what your app really does and have it reviewed before launch. It is not legal advice. This policy explains what [Company name] collects when you use this app, why we collect it and the choices you have. ## Who is responsible [Company name], [Postal address], is responsible for your personal data. Contact us at [privacy@your-domain.example]. ## What we collect Account data such as your name, email address, password hash, passkeys and two-factor settings. Content you create, such as organizations, todos and uploaded files. Technical data such as IP address, browser, sessions and security events in the audit log. ## Why we use it To provide and secure the service, to send account emails such as verification, password reset and invitations, to prevent abuse and to meet legal obligations. We do not sell personal data. ## Cookies and local storage Essential cookies keep you signed in and remember your language. Your theme and cookie choices are stored in your browser. Analytics or marketing tools, if enabled, load only after you consent in the cookie banner, and you can change that choice at any time. ## Service providers We use processors that act on our instructions: Cloudflare for hosting, email and file storage, Neon for the database, and any payment or notification provider we enable. ## AI agents you connect If you connect an AI agent over MCP, it can read your data and propose drafts only within the scopes you approve. You can revoke its access at any time under Integrations. ## How long we keep data We keep account data while your account exists. When you delete your account in Settings, we delete your personal data within [30] days unless the law requires us to keep it. Security logs are kept for [90] days. ## Your rights Depending on where you live, you can access, correct, export or delete your data, object to its processing and complain to a data protection authority. Write to [privacy@your-domain.example] and we will answer within one month. ## Changes to this policy We post changes on this page and update the date at the top. For material changes we also notify you by email or in the app. # Terms of Service Last updated October 12, 2026 > Template text: This page ships with the template. Replace the bracketed placeholders, adapt it to what your app really does and have it reviewed before launch. It is not legal advice. These terms govern your use of the app operated by [Company name]. By creating an account you agree to them. ## Your account Give accurate information, keep your password and passkeys safe, and tell us at [privacy@your-domain.example] if you think your account was compromised. You are responsible for activity under your account. ## Acceptable use Do not break the law, infringe the rights of others, upload malware, probe or overload the service, or try to access data you are not allowed to see. ## Your content You keep ownership of what you upload. You give us the permission we need to store, process and display it in order to provide the service. Organization admins can manage content shared in their organization. ## Paid features If paid plans are offered, prices, billing periods and cancellation terms are shown before you buy and become part of these terms. ## Availability and changes We work to keep the service available but cannot guarantee it will be uninterrupted. We may change or discontinue features and will give notice of material changes. ## Ending your use You can delete your account at any time in Settings. We may suspend or close accounts that break these terms. ## Liability The service is provided as is. To the extent the law allows, [Company name] is not liable for indirect or consequential damages. Nothing in these terms limits liability that the law does not allow us to limit. ## Governing law These terms are governed by the laws of [Country or state]. ## Contact Questions about these terms go to [privacy@your-domain.example]. # Public demo posts ## Hello from the edge https://template-pr-137.its-live.dev/posts/hello-edge Ada Lovelace, 2026-06-01 Why we built this template on Cloudflare Workers. This is a demo post used to showcase dynamic OG images. ## End-to-end type-safe RPC https://template-pr-137.its-live.dev/posts/type-safe-rpc Alan Turing, 2026-06-10 oRPC ties the API and the client together. Another demo post for the template. # Public demo profiles ## Ada Lovelace https://template-pr-137.its-live.dev/u/ada Building on the edge. ## Alan Turing https://template-pr-137.its-live.dev/u/alan Type-safety enthusiast. # Architectural decisions Read the original decisions, their constraints and their recorded evidence. The pages below render the Markdown files in `docs/adr` directly. The decision records are the source of truth; feature guides explain how to work within them. Each record starts with a status line: the status, the date it was accepted and, where it applies, the record that amends or supersedes it. An accepted decision changes only through a new record or a dated clarification. | ADR | Decision | Status | | --- | --- | --- | | [0001](/docs/adr/0001-stored-file-keys-keep-the-user-s-filename) | Stored file keys keep the user's filename | accepted · 2026-07-13 | | [0002](/docs/adr/0002-no-barrels-on-server-capable-modules) | Server-capable modules have no barrel | accepted · 2026-07-30 | | [0003](/docs/adr/0003-organizations-are-the-tenant) | Organizations are the tenant | accepted · 2026-09-17 | | [0004](/docs/adr/0004-payments-providers) | Payments: one entitlement read model, three provider modules | accepted · 2026-10-12 | | [0005](/docs/adr/0005-effect-boundary) | Effect stays at the server edges | accepted · 2026-10-12 · amended by 0010 | | [0006](/docs/adr/0006-effect-persisted-queue) | Todo imports and the PersistedQueue gate | accepted · 2026-10-13 | | [0007](/docs/adr/0007-ai-assistant-and-tool-catalog) | AI assistant and one tool catalog | accepted · 2026-10-14 | | [0008](/docs/adr/0008-documents-and-large-uploads) | Documents and direct uploads | accepted · 2026-10-15 | | [0009](/docs/adr/0009-row-level-security) | Row-level security isolates Organizations | accepted · 2026-10-16 | | [0010](/docs/adr/0010-procedures-run-one-effect-program) | Procedures run one Effect program at their edge | accepted · 2026-10-17 · amends 0005 | | [0011](/docs/adr/0011-backend-feature-shape) | Backend features have a fixed file shape | accepted · 2026-10-17 | # Backend shape Why every backend feature has the same files, why there is no barrel and no repository layer, and where Effect runs. The server code in `packages/api` follows one idea: **every feature has the same shape, and dependencies flow one way**. Once you have read one feature, you can read all of them. ## Features, not layers [#features-not-layers] A feature is a folder, `packages/api/src/features//`. Inside it, each file's name states its role: the contract clients import, the procedures oRPC exposes, the service that does the work, the errors it can fail with, the jobs the cron tick runs and the raw HTTP edges. The [backend conventions](/docs/development/backend-conventions) list the roles and what each may import. Grouping by feature instead of by layer keeps one capability in one place. You can follow a change without hopping between `routers/`, `services/` and `lib/`, and you can delete a feature as a folder. Shared building blocks that belong to no feature (procedure builders, the request context, the Effect edge, the job runner) live in `packages/api/src/platform/`. ## Boundaries the build can check [#boundaries-the-build-can-check] Three boundaries matter, and each one is enforced by a check rather than by discipline: - **Client and server.** A client imports only a leaf contract, an enum or a type. A barrel is how server code once reached the browser, so the package has none; its public surface is an enumerated `exports` map ([ADR-0002](/docs/adr/0002-no-barrels-on-server-capable-modules)). - **Tenant and system.** Procedures read and write through `context.db`, which runs as `app_tenant`. Row-level security, not a layer of code, keeps Organizations apart ([ADR-0009](/docs/adr/0009-row-level-security)). That is why there is no repository layer: it would forward calls to the same Drizzle handle and add a second place for every query without adding isolation. - **Feature and feature.** A feature uses another feature's contract, service or errors, not its internals. Only the app router imports procedures, and only the job runner imports jobs. The [enforcement matrix](/docs/development/enforcement) shows what checks each boundary. ## Errors and Effect [#errors-and-effect] A service describes what can go wrong as tagged errors. The procedure turns them into the stable codes clients localize, once, in one mapper. Services that a migration has reached are Effect programs: the procedure runs one program per call, so typed failures, deadlines and the rule that a started write is never abandoned have a single owner. Contracts stay Zod, routing stays oRPC and queries stay Drizzle ([ADR-0010](/docs/adr/0010-procedures-run-one-effect-program), [ADR-0005](/docs/adr/0005-effect-boundary)). ## Smells [#smells] You have drifted from this shape when you see any of these: - a procedure file full of queries in a migrated feature; - a service that imports `@orpc/*`; - a contract that imports a Drizzle table or a service; - a feature importing another feature's queue or lease module; - a list endpoint that pages differently from its neighbours; - a new error that is a string instead of a tagged error. Start a new feature with the `domain-feature` skill, which walks through these steps in order. # Client/server boundary Keep server capabilities out of browser and native module graphs. ## Import a leaf [#import-a-leaf] A server-capable module must not re-export a neighbor's convenience constant. A client that imports one enum from a Drizzle table module can pull the entire table graph into its bundle. That happened here and led to [ADR-0002](/docs/adr/0002-no-barrels-on-server-capable-modules). ```ts import { PRIORITIES } from "@template-cloudflare/db/schema/todo.enums"; import type { AppRouterClient } from "@template-cloudflare/api/client"; ``` Domain enums live in dependency-free `*.enums.ts` modules. Public Zod contracts live in `api/src/features/*/*.contract.ts`, importing only safe contracts where necessary. A `LEAF MODULE` header means no imports may be added to that module. The API client entry exports types only. ## Enforcement [#enforcement] 1. Nested Oxlint configs reject value imports of server modules from web, mobile and UI. Type-only imports are allowed. 2. Server package export maps enumerate supported subpaths. Add a specific entry deliberately; do not widen it to `./*`. 3. The web build scans emitted client assets for server-only markers with `assert-client-bundle-clean.mjs`. ```sh pnpm exec oxlint pnpm --filter web build ``` The Drizzle schema aggregate is the documented exception: the relations builder needs all tables, and clients cannot reach it. The existing web oRPC adapter also uses a framework-stripped server branch for SSR. Do not copy that exception into an ordinary component. ## Extending a contract [#extending-a-contract] Put the shared value in a leaf first. Make the table and schema import the leaf. Add its explicit package export, then import that address from consumers. Do not rely on tree-shaking to hide server side effects. Documentation articles render on the server so examples of server-only names stay out of executable client assets too. The browser receives trusted, repository-authored HTML and small navigation metadata. ## Key files [#key-files] - [`packages/config/oxlint-client-boundary.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/config/oxlint-client-boundary.json) - [`apps/web/scripts/assert-client-bundle-clean.mjs`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/scripts/assert-client-bundle-clean.mjs) - [`packages/api/src/client.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/client.ts) - [`packages/db/src/schema/todo.enums.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/db/src/schema/todo.enums.ts) # Effect at the edges Use stable Effect APIs for owned server work without changing public contracts. Effect coordinates scheduled execution and outbound webhook, push, RevenueCat and email transports. It also powers the diagnostic probe. Zod remains the contract validator, oRPC the request layer, Better Auth the identity system and Drizzle the database interface. ## One execution boundary [#one-execution-boundary] Jobs return `Effect`; the cron entry runs the composed program once. Promise consumers call thin facades through `runEdge`. Convert typed errors once with `catchTags` where the program meets HTTP, persisted outcomes or an external result union. Do not catch interruption manually. Use `tryPromise` when a rejection must enter the typed error channel. Use `drained` for promises that own database state: it requests cancellation and waits for settlement before closing the pool. Scope cleanup can outlive an Effect deadline when external I/O does not settle; the query timeout is part of the shutdown design. ## Retries and deadlines [#retries-and-deadlines] | Policy | Production value | | --- | --- | | Whole scheduled tick | 25 seconds | | Import admission budget | 15 seconds | | Import source GET | 10 seconds | | Job pool query timeout | 2 seconds | | Job pool SQL statement timeout | 5 seconds | `idempotentRead` retries eligible reads twice with jitter and a budget. A short Retry-After can be honored inside the round; a longer delay returns to persistent policy. A webhook POST or email send is not retried inside that round. ## Stable API boundary [#stable-api-boundary] Read the installed Effect `AGENTS.md` and source before editing Effect code. Only APIs marked stable are approved. Native PersistedQueue failed the workerd lost-acknowledgement shutdown gate; production imports use stable Queue/Stream/Schedule plus Drizzle leases. Do not replace that queue or bump its pins without repeating [ADR-0006](/docs/adr/0006-effect-persisted-queue)'s fault gate. TanStack AI owns its Promise/SSE lifecycle under the narrow exception in [ADR-0007](/docs/adr/0007-ai-assistant-and-tool-catalog). Adding Layers or shared services is a new architectural decision, not a prerequisite for a small transport. ## Key files [#key-files] - [`packages/api/src/platform/effect/edge.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/effect/edge.ts) - [`packages/api/src/platform/effect/retry.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/effect/retry.ts) - [`packages/api/src/platform/jobs/run.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/jobs/run.ts) - [`docs/adr/0005-effect-boundary.md`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/adr/0005-effect-boundary.md) # Architecture One Worker owns web rendering, API requests and scheduled execution. The production web app is a TanStack Start application built with the Cloudflare Vite plugin. Its Worker handles SSR, Better Auth, oRPC, the files gateway, MCP, AI streaming, OG images and cron. Expo calls the same HTTP API. There is no separate API deployment. ## Runtime and infrastructure [#runtime-and-infrastructure] | Component | Responsibility | | --- | --- | | TanStack Start | Routes, SSR, hydration and request handlers | | Worker fetch | Carries Cloudflare waitUntil into the request runtime before dispatch | | Hyperdrive | Pools connections to Postgres; query caching must stay disabled | | Postgres, hosted on Neon | Users, Organizations, domain records, leases, outboxes and approvals | | R2 | Private objects, accessed through the files-sdk fetch adapter | | Worker scheduled | Runs the six jobs in one scoped Effect program | The default build targets Cloudflare. `DEPLOY_TARGET=node` selects Nitro for a Node deployment. `vite dev` always uses Nitro, with a shim for `cloudflare:workers` and direct `DATABASE_URL` access. The Node path is useful locally; the built Worker remains the compatibility test for production. ## Read the implementation [#read-the-implementation] - [Backend shape](/docs/architecture/backend-shape) explains why every feature has the same files. - [Request lifecycle](/docs/architecture/request-lifecycle) follows one authenticated request. - [Client/server boundary](/docs/architecture/client-server-boundary) explains which imports are safe. - [Effect at the edges](/docs/architecture/effect) covers deadlines, cancellation and retries. - [Observability](/docs/architecture/observability) distinguishes application logs from Worker traces. Shared code belongs in a package when web and mobile need the same contract or behavior. The [package reference](/docs/packages) lists actual exports. Adding a feature usually adds a folder under `packages/api/src/features/` and extends the existing Worker and schema, rather than provisioning another service. ## Key files [#key-files] - [`apps/web/src/server.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/server.ts) - [`apps/web/vite.config.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/vite.config.ts) - [`apps/web/wrangler.jsonc`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/wrangler.jsonc) - [`packages/db/src/connection.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/db/src/connection.ts) # Observability and tracing Read structured errors, durable audit records and Cloudflare spans as separate signals. ## Application errors and logs [#application-errors-and-logs] The web middleware integrates evlog. Shared error codes and localized messages come from `packages/errors`; its server handler logs unexpected failures while the client resolver returns a safe user message. During development, inspect `.evlog/logs/` when available. Do not copy credentials, prompts or private payloads into logs. Each RPC request has a request ID: `cf-ray` when Cloudflare set a well-formed one, otherwise a UUID. It is `context.requestId` on the server, the `x-request-id` header on every `/api/rpc` response (including the 500 when building the context fails), and a field of the error log. The MCP context derives its own `requestId` for the logs of its document tools; MCP responses do not carry the header. It serves only to correlate logs, never to decide anything: outside Cloudflare (local `wrangler dev`, Node) a client can send a well-formed `cf-ray` and it is used as is, and [Cloudflare's header reference](https://developers.cloudflare.com/fundamentals/reference/http-headers/) does not say whether a client-sent value is replaced. The error handler writes expected 4xx failures at debug level and unexpected ones (5xx, unknown codes, non-oRPC errors) as errors, both with the request ID, so a user's quoted header finds the matching line ([error handling](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/error-handling.md)). An Audit Event is a durable domain record, not a log line. The Audit Log is the admin table; an Activity Timeline is one User's security history. Release notes belong to the public [changelog](/docs/changelog). ## Worker traces [#worker-traces] Wrangler enables observability and traces. The scheduled handler passes `ctx.tracing` into `runScheduledJobs`; `workersTracer` adapts Effect spans to Cloudflare spans. It records scalar attributes, failures and interruption. The native API cannot assign an explicit parent to each Effect span. Those spans appear as siblings under `jobs.scheduled`, alongside platform fetch and Hyperdrive spans. Node execution runs without this tracer. Local successful jobs do not prove that production custom spans are visible in the Cloudflare dashboard. ## Diagnose a failure [#diagnose-a-failure] Trace the entry point, transport result and durable row together. For an import, inspect its state and persisted failure reason before assuming the queue lost it. For a webhook, inspect the delivery lease and next attempt. For a client error, keep the stable error code and correlation context rather than exposing an exception message. Bundle and fault experiments are recorded in `docs/measurements`. Their local timings are not production latency or billing measurements. Add a dated report when changing runtime ownership, deadlines or a heavy dependency. ## Key files [#key-files] - [`packages/api/src/platform/rpc/request-id.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/rpc/request-id.ts) - [`packages/errors/src/server.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/errors/src/server.ts) - [`packages/api/src/platform/effect/tracer.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/effect/tracer.ts) - [`packages/db/src/audit.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/db/src/audit.ts) - [`apps/web/wrangler.jsonc`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/wrangler.jsonc) # Request lifecycle Follow authority and database lifetime from the incoming Request to the response. ## Entry and request scope [#entry-and-request-scope] `apps/web/src/server.ts` wraps the TanStack handler in `runWithRequestRuntime`. This preserves `waitUntil` without opening a database connection for every public page. When a protected operation needs auth, `createRequestScope(request)` supplies one database and one Better Auth instance. The oRPC context also derives the request ID (`cf-ray` or a UUID), which the RPC route returns as `x-request-id`, also on the 500 it answers when building the context itself fails, and which the error log records. It correlates logs only; since a client outside Cloudflare can supply the `cf-ray`, nothing authorizes or deduplicates by it. The scope uses AsyncLocalStorage across asynchronous continuations and a WeakMap for the same Request object. SSR prefetch and cloned Requests can reuse the current scope. A different HTTP request gets a different Worker pool because a Worker socket belongs to the invocation that opened it. Node development reuses its long-lived pool instead. ## Authentication and authorization [#authentication-and-authorization] The oRPC context calls `getAuthorizedSession`, which bypasses cookie caching and rejects an active Ban. `orgProcedure` additionally looks up current membership in the Session's Active Organization, reading the Member's Org role in the same statement; `requireOrgRole(...)` can then restrict a procedure to some roles. `adminProcedure` re-reads the authoritative Session before checking the platform role. ```ts // Server router: orgProcedure supplies a membership-checked organizationId. const list = orgProcedure.handler(async ({ context }) => { return context.db.query.todo.findMany({ where: { organizationId: context.organizationId }, }); }); ``` Mobile requests can carry the expected user/session/organization scope. The server refuses a changed scope rather than answering a request under a new identity. A client abort is honoured only before a procedure's program starts. After that, a client that goes away does not interrupt it: the write and its Audit Event commit and the response goes unread, so cancelling the wait never undoes or half-applies a mutation. ## Commit and cleanup [#commit-and-cleanup] Audited writes enqueue an Audit Event in the same database transaction as the domain change, then attempt publication. The durable audit job recovers unpublished records. AI streams and scheduled jobs own separately bounded pools and explicitly close them when their work settles. Do not cache a request-bound auth instance, database client or Layer at module scope. Public docs and discovery routes read compiled public content and do not require a Session. ## Key files [#key-files] - [`packages/auth/src/request-scope.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/auth/src/request-scope.ts) - [`packages/auth/src/server-session.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/auth/src/server-session.ts) - [`packages/api/src/platform/rpc/context.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/rpc/context.ts) - [`packages/api/src/platform/rpc/procedures.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/rpc/procedures.ts) - [`packages/db/src/index.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/db/src/index.ts) # Changelog New features, architectural changes and improvements to the template. Release notes are curated from repository history. Dates reflect integration into the main branch; grouped entries are not semantic-version tags. Each entry links to its implementation and current developer guide. ## One folder per backend feature, tagged errors and written rules 2026-10-17 packages/api moves to features and platform folders with fixed file names, two pilot features run one Effect program per procedure, and every rule now names what enforces it. **Breaking for imports of `@template-cloudflare/api`.** Server code moved from `routers/`, `lib/` and topic folders to `packages/api/src/features//..ts`, with shared building blocks in `packages/api/src/platform/`. Public subpaths now follow `.//`, for example `./todo/contract`, `./app-router`, `./uploads/service` and `./assistant/http`. If your fork imports an earlier subpath, use the table in the [api package reference](/docs/packages/api#renamed-subpaths). The four subpaths the mobile app imports (`./routers/{todo,integrations,billing,notifications}.schema`) remain as aliases, so the native app builds unchanged. - **Feature shape.** Every feature has the same role files: a leaf contract, procedures, a service, errors, and optional jobs and HTTP edges. There is no barrel and no repository layer; row-level security already isolates Organizations. The `@template-cloudflare/api/mcp/*` subpaths are server-only in the client boundary. New tests and lint rules hold the shape: leaf modules import only leaves, features import each other through declared edges, there are no import cycles, type imports are explicit, and configuration is read through `packages/env`. [Backend shape](/docs/architecture/backend-shape), [ADR-0011](/docs/adr/0011-backend-feature-shape). - **Tagged domain errors and Effect in procedures.** In `todo` and `documents`, services fail with tagged errors, and one mapper per feature turns them into the same `APP_ERRORS` codes and messages clients saw before. Each procedure runs its service once through `runProcedure`. A client abort is honoured only before the program starts; once it runs, a client that goes away no longer rolls back the write, so the write and its Audit Event commit and the response goes unread. Other features keep their services until they are migrated. [ADR-0010](/docs/adr/0010-procedures-run-one-effect-program), [error handling](/docs/development/backend-conventions#domain-failures-are-tagged-errors-mapped-once). - **Request ID and quieter logs.** Every RPC response carries `x-request-id`, including the 500 when building the request context fails: Cloudflare's `cf-ray` when it is well formed, otherwise a UUID. The MCP context derives its own request ID for the logs of its document tools. The same ID is `context.requestId` on the server and is part of the error log, so a user can quote it. It correlates logs only: outside Cloudflare a client can supply the `cf-ray`, so nothing decides by it. Expected 4xx failures are logged at debug level instead of as errors. Runtime switches (`DB_TENANT_PIPELINE`, `TRUSTED_PROXY_HEADER`, `TRUSTED_PROXIES`, `TENANCY_PROBE`) are declared in the env schema. Their meaning is unchanged, but an invalid value is now reported instead of silently replaced by a default; check these variables in your Worker and Node deployments. - **Input validation answers HTTP 400.** An invalid input now returns HTTP 400 with the same `INPUT_VALIDATION` code, message and field errors as before; it used to come back as HTTP 500, because oRPC's default status map did not know the code. The RPC and OpenAPI handlers use `errorStatusMap` from `@template-cloudflare/errors/server`, and `APP_ERRORS.INPUT_VALIDATION.status` is 400 instead of 422, which also changes the status in the generated OpenAPI document. The assistant's HTTP route maps statuses with the same shared map, so `INPUT_VALIDATION` is 400 there as well. Clients that branch on `code` see no change. [Error handling](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/error-handling.md). - **Org roles, ready when needed.** `orgProcedure` reads the Member's Org role with its membership check, and `requireOrgRole(...)` restricts a procedure to some roles, failing closed. Roles are not hierarchical (list every role that may act), and using it outside `orgProcedure` is a type error. No procedure applies it yet. [Backend conventions](/docs/development/backend-conventions#org-roles-are-checked-only-where-a-product-rule-needs-them). - **Written rules with their checks.** [Backend conventions](/docs/development/backend-conventions) state each rule with its reason, example, anti-pattern and enforcement. The [enforcement matrix](/docs/development/enforcement) shows which rules a check holds and which rely on review. A [review checklist](/docs/development/review-checklist), a pull request template and a root `CODING_STANDARDS.md` index the same rules. - **Runbooks.** [Runbooks](/docs/development/runbooks) cover a database restore or host move, the pipelining kill switch, Hyperdrive caching, the RLS proof on a preview, and the emergency shutdown. The emergency-shutdown workflow does not block a custom domain; the runbook gives the extra step. - **Decisions show their status.** Every ADR starts with its status and acceptance date, and the [decision index](/docs/adr) lists them. The [pagination](/docs/features/api#pagination) and [testing](/docs/development/testing#what-to-test) rules are written down. - **A skill for new features.** The `domain-feature` skill walks from a glossary term to an RLS table, contract, tagged errors, service, procedures, isolation tests and docs. Measurements ([report](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/measurements/2026-10-17-backend-structure.md)): the server code grows by 6.9 KiB gzip, within the 10 KiB budget for the Effect pilots, and the client carries no Effect. The happy-path p50 of the pilot procedures is within the noise of an A/A run; the `todo.byId` error path costs +0.040 ms. Separately, the Worker as a whole grows by 70.2 KiB gzip, of which 63.4 KiB are the documentation pages, ADRs and changelog the site renders on the server: that is content and has nothing to do with Effect, and whether it counts against the Worker budget is still open. Sources: [platform move](https://github.com/CarlosZiegler/template-cloudflare/commit/0aa202e), [feature folders, first of 18 moves](https://github.com/CarlosZiegler/template-cloudflare/commit/3d1913f), [graph tests](https://github.com/CarlosZiegler/template-cloudflare/commit/2226f26), [direction rules](https://github.com/CarlosZiegler/template-cloudflare/commit/05836ac), [runProcedure](https://github.com/CarlosZiegler/template-cloudflare/commit/91a2d11), [todo pilot](https://github.com/CarlosZiegler/template-cloudflare/commit/6c77fb1), [documents pilot](https://github.com/CarlosZiegler/template-cloudflare/commit/49a4de0), [request ID](https://github.com/CarlosZiegler/template-cloudflare/commit/fa1cbbb), [error log levels](https://github.com/CarlosZiegler/template-cloudflare/commit/ec63950), [runtime switches](https://github.com/CarlosZiegler/template-cloudflare/commit/0df1feb), [trusted proxy](https://github.com/CarlosZiegler/template-cloudflare/commit/47a3700), [requireOrgRole](https://github.com/CarlosZiegler/template-cloudflare/commit/3e51803), [400 for input validation](https://github.com/CarlosZiegler/template-cloudflare/commit/146d310), [measurement](https://github.com/CarlosZiegler/template-cloudflare/commit/ec94469). ## AI assistant and one tool catalog 2026-10-15 The assistant, MCP and agent-readable pages now share the application’s authorized operations. The web assistant adds persisted Threads, streamed responses and explicit approval for mutating tools. Tool execution uses one server catalog shared with MCP, preserving current Session and Organization authorization. Provider configuration and the `ai.assistant` flag jointly control availability. Agent discovery now exposes public instructions and Markdown pages alongside OAuth connection metadata. The supported transport keeps TanStack AI’s Promise/SSE lifecycle separate from the Effect job boundary. - [AI assistant and approvals](/docs/features/assistant) - [MCP server](/docs/features/mcp) and [connecting an agent](/docs/connect-your-agent) - [Agent-readable pages](/docs/features/agent-pages) [Source: assistant integration](https://github.com/CarlosZiegler/template-cloudflare/commit/63e36e6) ## Dependency round, i18next fix and ReUI controls 2026-10-15 Eligible packages advance under the two-day quarantine, a reachable i18next ReDoS is patched and six free ReUI controls join the UI package. i18next 26.4.4 fixes [GHSA-5jr9-983r-c8x4](https://github.com/advisories/GHSA-5jr9-983r-c8x4), a ReDoS reachable through the intl package. The expired TanStack quarantine exceptions were removed, so the workspace keeps its 2880-minute `minimumReleaseAge` with no exclusions. Vulnerable transitive ranges such as `hono`, `qs`, `undici` and `@modelcontextprotocol/sdk` were re-resolved within their declared ranges. Direct pins advance for zod, vitest, TanStack Query and Table, evlog, lucide-react, the AWS S3 clients and Takumi 2.15.1. files-sdk 2.6.2 drops the error-identity workaround; the rebased patch keeps upload authorization, Organization prefix, declared size/type and conditional-create checks. Wrangler, the Vite plugin, oxlint and turbo stayed on their previous versions until their releases qualify. The UI package adds free ReUI `time-picker`, `color-picker`, `signature-pad`, `file-type`, `emoji-picker` and `cascader` components with explicit exports. The cascader brings `@tanstack/react-virtual`. EmojiPicker loads `emojibase-data` from jsDelivr unless local `data` is passed. - [Libraries and version policy](/docs/libraries) - [UI package](/docs/packages/ui) Sources: [security fix](https://github.com/CarlosZiegler/template-cloudflare/commit/bea0767), [package bumps](https://github.com/CarlosZiegler/template-cloudflare/commit/40e1390), [transitive ranges](https://github.com/CarlosZiegler/template-cloudflare/commit/9c59445), [ReUI controls](https://github.com/CarlosZiegler/template-cloudflare/commit/8fc6177). ## Earlier updates - 2026-10-15: [Developer documentation and changelog](/docs/changelog/2026-10-15-developer-docs-and-changelog) - 2026-10-15: [Documents and large uploads](/docs/changelog/2026-10-15-documents-and-large-uploads) - 2026-10-15: [Landing page redesign](/docs/changelog/2026-10-15-landing-redesign) - 2026-10-14: [Trace and code block primitives](/docs/changelog/2026-10-14-trace-and-code-blocks) - 2026-10-13: [TanStack Start 1.0, OG images and durable imports](/docs/changelog/2026-10-13-start-og-and-imports) - 2026-10-12: [Payments, native screens and a new landing page](/docs/changelog/2026-10-12-payments-and-native-experience) - 2026-10-03: [Runtime and recovery hardening](/docs/changelog/2026-10-03-runtime-and-recovery) - 2026-09-25: [Authentication foundations and performance](/docs/changelog/2026-09-25-auth-and-performance) - 2026-09-24: [Platform dependency alignment](/docs/changelog/2026-09-24-platform-dependencies) - 2026-09-03: [oRPC and Drizzle upgrades](/docs/changelog/2026-09-03-typed-api-upgrade) - 2026-08-25: [Typed domain operations and a real dashboard](/docs/changelog/2026-08-25-typed-domain-and-dashboard) - 2026-08-24: [Agent connections and native account security](/docs/changelog/2026-08-24-agent-connections-and-account-security) - 2026-08-13: [Cloudflare email and shared UI](/docs/changelog/2026-08-13-email-and-ui) - 2026-07-30: [An enforced client/server boundary](/docs/changelog/2026-07-30-client-boundary) - 2026-07-24: [Native interface foundations](/docs/changelog/2026-07-24-native-foundation) - 2026-07-13: [Administration and data workflows](/docs/changelog/2026-07-13-admin-and-data-workflows) - 2026-06-10: [Setup, storage and localization](/docs/changelog/2026-06-10-setup-storage-and-i18n) # Deployment and rollback GitHub Actions deploy isolated previews and the production Worker with reviewed migrations. ## Production [#production] A push to `main` triggers `deploy-prod.yml`. Its concurrency group serializes production deployment without cancelling an in-flight run. The workflow checks out the revision, installs the frozen lockfile, applies pending database migrations, builds the Worker and deploys it with Wrangler. `pnpm deploy` is the explicit local deployment path. It uses configured credentials and can change production state. Routine development and bundle measurement should use a build and `wrangler deploy --dry-run` instead. ## Pull request previews [#pull-request-previews] The preview workflow uses `scripts/preview.mjs` to derive and validate an isolated Worker/database target. It ensures the preview branch/configuration and, when production uses Hyperdrive, a Hyperdrive of the PR's own (named after the preview Worker, query caching disabled, pointing at the PR's database branch) so the preview takes the production connection path. It applies migrations through the named preview migration URL, builds, and deploys the preview with its secrets in the same version (`wrangler deploy --secrets-file`): the env schema validates when the Worker loads, so a new Worker must never start without them. Then it checks the runtime. Closing the PR deletes the Worker, its Hyperdrive and its database branch from trusted default-branch code. The CI token needs Hyperdrive edit permission. Preview isolation is a correctness boundary: a preview must never silently reuse production auth, storage or database resources. Read the workflow and isolation tests before changing naming, credentials or teardown. ## Configuration and secrets [#configuration-and-secrets] Cloudflare API credentials authorize the workflow; Worker runtime secrets are separate. Database access, Better Auth, R2/files and optional providers must be configured for the target environment. `VITE_SITE_URL` is a public build value, not a secret. Setup and the deployment skill describe the concrete secret names and provider steps. Hyperdrive points to Postgres with query caching disabled: cached reads are keyed by query text and parameters, not by the tenant role and settings row-level security uses. `pnpm setup --hyperdrive-id ` disables it (`wrangler hyperdrive update --caching-disabled`), and the production deploy refuses to publish when `node packages/project-setup-cli/src/hyperdrive.mjs check` finds caching on. R2 uses the environment’s intended bucket. The Worker owns fetch and scheduled entry points; cron runs the existing job program. Adding a feature does not require another Worker by default. ## Migrations and rollback [#migrations-and-rollback] Migrations run before the new code is deployed. Review compatibility with the previous Worker because that version can still serve traffic while the database changes. Prefer additive changes and staged removal over a destructive migration coupled to one deploy. To roll back code, identify the known-good Worker deployment/version in Cloudflare and use the supported Wrangler rollback command for the exact target. Verify runtime routes and scheduled behavior afterward. A Worker rollback does **not** reverse Postgres migrations, restore deleted records or undo external deliveries. Database recovery requires its own reviewed plan and backup/restore procedure. ## Verification [#verification] Build and run the produced Worker locally, then validate the preview URL and its discovery/auth behavior before merging. Check workflow logs for migration and target selection, and production traces for job execution after deployment. Never treat a green Node dev server as workerd compatibility evidence. ## Key files [#key-files] - [`.github/workflows/deploy-prod.yml`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/.github/workflows/deploy-prod.yml) - [`.github/workflows/pr-preview.yml`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/.github/workflows/pr-preview.yml) - [`scripts/preview.mjs`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/scripts/preview.mjs) - [`.agents/skills/deploy-cloudflare/SKILL.md`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/.agents/skills/deploy-cloudflare/SKILL.md) # Backend conventions The rules for code in packages/api, each with its reason, an example, the anti-pattern and what enforces it. These rules apply to `packages/api`. Each one has the same shape: **rule**, **why**, **example**, **anti-pattern** and **enforcement**. The enforcement level is one of `[type]`, `[lint]`, `[test]`, `[build]`, `[deploy]` or `[convention]`. A `[convention]` rule depends on review alone, so it is the first candidate for a stronger check. The reasons behind the larger rules are recorded in [ADR-0011](/docs/adr/0011-backend-feature-shape) (feature shape) and [ADR-0010](/docs/adr/0010-procedures-run-one-effect-program) (Effect in procedures). ## One folder per feature, one name per role [#one-folder-per-feature-one-name-per-role] **Rule.** A feature lives in `packages/api/src/features//`. Its files are named `..ts`: | Role | File | Required | | --- | --- | --- | | Contract | `.contract.ts` | when the feature has procedures | | Procedures | `.procedures.ts` | when the feature is exposed through oRPC | | Service | `.service.ts` | in migrated features | | Errors | `.errors.ts` | when the feature has domain failures | | Rules, enums, policy | `.rules.ts`, `.enums.ts`, `.policy.ts` | optional, no I/O | | Jobs | `.jobs.ts` | when the feature runs in the cron tick | | Raw HTTP edge | `.http.ts` | when a Worker route sits outside oRPC | | Internals | `..ts` | as needed | | Tests | `..test.ts`; procedure tests are `.procedures.test.ts` | as needed | Code that belongs to no feature lives in `packages/api/src/platform/` (`rpc`, `effect`, `jobs`, `http`, `data-table`). **Why.** The name of a file tells you what it may import and who may import it. A feature can be read, moved or deleted as one folder. **Example.** ```text features/documents/ documents.contract.ts documents.procedures.ts documents.service.ts documents.errors.ts documents.policy.ts documents.jobs.ts documents.queue.ts documents.chunk.ts documents.lifecycle-plugin.ts ``` **Anti-pattern.** A sub-folder per layer (`documents/services/`), a second name for the same role (`router.ts` next to `procedures.ts`), or queries written inside the procedures file of a migrated feature. **Enforcement.** `[test]` `scripts/feature-boundaries.test.mjs` (in `pnpm test`) · `[lint]` direction overrides in `.oxlintrc.json`. ## The contract is a leaf [#the-contract-is-a-leaf] **Rule.** `.contract.ts` starts with the `LEAF MODULE` header. It imports only `zod`, other leaves (`db/schema/*.enums`, `*.enums.ts`, other contracts) and `import type` from anywhere. **Why.** Web and mobile import the contract. One value import of a server module puts server code in a browser bundle or an app binary ([ADR-0002](/docs/adr/0002-no-barrels-on-server-capable-modules)). **Example.** ```ts // LEAF MODULE: imported by web and mobile. Only zod, leaves and `import type`. import { PRIORITIES } from "@template-cloudflare/db/schema/todo.enums"; import { z } from "zod"; export const todoCreateInput = z.object({ text: z.string().min(1), priority: z.enum(PRIORITIES) }); ``` **Anti-pattern.** Importing a constant from a Drizzle table module, or from a service, "because it is already there". **Enforcement.** `[test]` `scripts/leaf-modules.test.mjs`, which also fails when a `features/*/*.contract.ts` lacks the header · `[lint]` `packages/config/oxlint-client-boundary.json` · `[build]` `assert-client-bundle-clean.mjs`. ## No barrel; the public surface is `exports` [#no-barrel-the-public-surface-is-exports] **Rule.** No `index.ts` re-exports a feature. Other packages and apps import an enumerated subpath of `@template-cloudflare/api`. Canonical subpaths are `.//`, for example `./todo/contract` and `./billing/http`. The mobile app keeps four legacy aliases (`./routers/{todo,integrations,billing,notifications}.schema`) that point at the same files. The web app uses canonical subpaths only. **Why.** A barrel is how server code reached the browser. An enumerated map makes every new cross-package import a reviewed decision. **Example.** `import { todoListInput } from "@template-cloudflare/api/todo/contract";` **Anti-pattern.** A wildcard `"./*"` export, or a web import of a legacy `routers/*.schema` alias. **Enforcement.** `[test]` `packages/api/src/exports.test.ts`: every target exists, each alias points at its canonical file, nothing exports a procedure, job or service beyond the listed server entries, every subpath web and mobile import exists, and the web app uses no alias. The alias ban is a test rather than an oxlint rule because an oxlint override replaces a rule's options instead of merging them: banning the aliases in `apps/web/src` would mean repeating the whole client boundary there. ## Imports flow one way [#imports-flow-one-way] **Rule.** - Only `platform/rpc/app-router.ts` and the agent tool catalog import `*.procedures`. - Only `platform/jobs/run.ts` imports `*.jobs`. - `platform/**` does not import features, except those two files and two declared edges: `platform/rpc/procedures.ts` reads `features/admin/admin.roles` (the admin guard) and `platform/rpc/context.ts` builds `features/admin/admin.api` (the `adminApi` capability). - A feature imports another feature's contract, enums, service, errors, policy or rules. Any other cross-feature import is declared in the boundary test. - In a migrated feature, `*.procedures.ts` imports no `drizzle-orm` and no table module (enums are fine): queries live in the service. The lint rule refuses value imports of `drizzle-orm`, the schema barrel `@template-cloudflare/db/schema` and the per-table modules `@template-cloudflare/db/schema/` (enum leaves and `import type` pass). A relational query through `context.db.query.*` needs no import, so the rule cannot see it; that one is left to review. - There are no import cycles in `packages/api/src` and `packages/db/src`. **Why.** Each rule keeps a dependency the reader can predict. A domain module that imports a router or a job is the first sign the layering has rotted. **Example.** `todo.service.ts` imports `enqueueUserWebhooks` from `webhooks.events.ts`, a declared edge, to queue deliveries inside its transaction. **Anti-pattern.** A service that imports another feature's queue or lease module to "reuse a query". **Enforcement.** `[lint]` `no-restricted-imports` overrides in `.oxlintrc.json`: D3 (procedures), D4 (jobs), D5 (platform) and D8 (queries in migrated procedures, listed per feature) · `[test]` `scripts/feature-boundaries.test.mjs` (D6; a declared edge that disappears also fails, so the list only shrinks) · `[test]` `scripts/import-cycles.test.mjs` (D7). The cycle check runs in `pnpm test`, not in `pnpm lint`, because oxlint's `import` plugin made the whole lint about 3.7 times slower. ## Tenant data goes through `context.db` [#tenant-data-goes-through-contextdb] **Rule.** A procedure, tool or gateway reads and writes tenant data through `context.db`, which runs as `app_tenant` for the session's User and Active Organization. The `organizationId` filter stays in every query. Crossing tenants is possible only through `asSystem(db, reason, fn)` with a listed `SystemReason`, and the work happens inside `fn`. **Why.** Row-level security enforces isolation in the database; the filters are defence in depth ([ADR-0009](/docs/adr/0009-row-level-security)). **Example.** ```ts export const listTodos = Effect.fn("todo.list")(function* (db: Database, organizationId: string, input: TodoListInput) { return yield* query(() => db.select().from(todo).where(eq(todo.organizationId, organizationId))); }); ``` **Anti-pattern.** Opening a pool with `createDb` in a feature. Returning the handle from `asSystem`. Network I/O inside `withTenant`. **Enforcement.** `[lint]` the pool-import restriction and `check-system-call-sites.mjs` · `[test]` RLS isolation, catalog and matrix suites · `[convention]` no network I/O inside `withTenant`. ## Org roles are checked only where a product rule needs them [#org-roles-are-checked-only-where-a-product-rule-needs-them] **Rule.** Membership in the Active Organization is the default authorization for Organization data (`orgProcedure`). When a product rule limits an operation to some Org roles, chain `requireOrgRole` from `platform/rpc/require-org-role.ts` after `orgProcedure`. Do not compare role strings inside a service. **Why.** `orgProcedure` already reads the Member's role in its membership statement, so the check costs no extra round trip, and a missing or unknown role fails closed with `FORBIDDEN`. Row-level security checks membership, not roles, so the role check belongs in the procedure ([ADR-0009](/docs/adr/0009-row-level-security)). **Example.** ```ts remove: orgProcedure .use(requireOrgRole("owner", "admin")) .input(z.object({ id: z.number() })) .handler(({ context, input, signal }) => runProcedure(deleteTodo(context.db, context.organizationId!, input.id), todoRpcError, { signal, requestId: context.requestId, }), ), ``` Roles are not hierarchical: `requireOrgRole("admin")` refuses an `owner`, so list every role that may act. Chain it after `orgProcedure`, which reads the role: the middleware requires `organizationRole` in its input context, so `protectedProcedure.use(requireOrgRole(...))` is a type error. The middleware exists and is tested (owner, admin and `admin,member` pass; member and unknown roles are refused), but no procedure applies it yet: today every Member may do everything in their Organization. **Anti-pattern.** Reading `member.role` again in a service, or treating the platform role (`user`/`admin`) as an Org role. **Enforcement.** `[test]` `platform/rpc/require-org-role.test.ts` · `[convention]` where it is applied. ## Domain failures are tagged errors, mapped once [#domain-failures-are-tagged-errors-mapped-once] **Rule.** A failure a caller can act on is a `Schema.TaggedError` in `.errors.ts`. The feature exports one mapper, a `switch` on `_tag` ending in `satisfies never`, that turns each failure into an `ORPCError` with an `APP_ERRORS` code. Services never import `@orpc/*`. **Why.** The failures of an operation become a closed type, the mapper is exhaustive by construction, and the client keeps seeing the stable codes it localizes ([error handling](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/error-handling.md)). **Example.** ```ts // abridged from packages/api/src/features/documents/documents.errors.ts export class DocumentNotFound extends Schema.TaggedError()("DocumentNotFound", {}) {} export type DocumentError = DocumentNotFound | DocumentSourceChanged; export function documentRpcError(error: DocumentError) { switch (error._tag) { case "DocumentNotFound": return new ORPCError("NOT_FOUND"); case "DocumentSourceChanged": return new ORPCError("CONFLICT", { message: "The original document changed" }); default: return error satisfies never; } } ``` Keep the code and message the client saw before a migration. `documents.errors.ts` repeats the messages the service used to throw; `todo.errors.ts` passes `APP_ERRORS.NOT_FOUND.message`, because the handler used to throw `errors.NOT_FOUND()`. A `switch` over a union with one member ends in `return error._tag satisfies never`. **Anti-pattern.** `throw new ORPCError("CONFLICT")` inside a service. A string error. A new code that is missing from a locale. **Enforcement.** `[type]` the exhaustive mapper · `[lint]` D2 in `.oxlintrc.json`: no `@orpc/*` in the services, rules and policies of migrated features, listed by path, with the unmigrated features named in the override's comment · `[test]` characterization tests in `packages/api/src/testing/characterization/`, locale tests. ## A procedure runs one program [#a-procedure-runs-one-program] **Rule.** In a migrated feature, the handler calls `runProcedure` once with the service program, the feature mapper, the request `signal` and the request ID. The `signal` is honoured only before the program starts: an already aborted request is refused, but once the program runs, a client that goes away does not interrupt it, so the write and its Audit Event commit and the response goes unread. A service is an `Effect.fn` that receives `db` and `organizationId` as arguments. Database work uses `query` and `transaction` from `platform/effect/db.ts`. Both drain: if the program itself is interrupted (a timeout or deadline inside it), the statement or transaction settles before the program ends. `query(run)` hands `run` that interruption signal, not the client's, which a service may pass to an external read (the documents pilot passes it to the storage `HEAD`). **Why.** There is one place where typed failures become transport errors and where cancellation is handled. An audited procedure runs inside the transaction `withAudit` opens around the handler, so a handler that rejected because the client left would roll back the write and its audit; finishing the started program prevents that (`todo.abort.test.ts`, `documents.abort.test.ts`). A running transaction is drained instead of abandoned ([ADR-0010](/docs/adr/0010-procedures-run-one-effect-program), [ADR-0005](/docs/adr/0005-effect-boundary)). **Example.** ```ts source: orgProcedure.input(documentIdentityInput).handler(({ context, input, signal }) => runProcedure(documentSource(context.db, context.organizationId!, input.fileId), documentRpcError, { signal, requestId: context.requestId, }), ), ``` **Anti-pattern.** - `Effect.runPromise` inside a service. - A second `run*` in the same call. - A `Layer`, `Context.Service` or `ManagedRuntime` that holds a pool, whether at module scope or per request. - Handling interruption by hand. **Enforcement.** `[test]` `platform/rpc/procedure-edge.test.ts` and `platform/effect/db.test.ts` · `[lint]` D8 keeps queries out of migrated procedures · `[convention]` one `runProcedure` per handler, no `Layer` or runtime. ## External I/O has a deadline; only reads retry [#external-io-has-a-deadline-only-reads-retry] **Rule.** A service that calls storage or a provider sets a timeout. It retries only idempotent reads, with `idempotentRead`. A PUT, a provider POST, a webhook delivery or an email send makes one attempt. **Why.** A repeated write can duplicate a side effect. The outbox and lease policy decides when to try again. **Example.** `idempotentRead` wraps the RevenueCat subscriber `GET`, Expo `getReceipts` and the import source reads. The storage `HEAD` that confirms Document Text (`documents.service.ts`) has a 10 s timeout, `retries: 0` and the program's abort signal, and runs before any lock or transaction; the documents pilot kept that behavior instead of adding a retry. The signed `PUT` the browser performs is never retried by the server. **Anti-pattern.** `Effect.retry` around a POST. **Enforcement.** `[convention]` · `[test]` retry tests with `TestClock`. ## Configuration is declared once [#configuration-is-declared-once] **Rule.** Every environment variable is declared in `packages/env` and read through it, including runtime switches such as `DB_TENANT_PIPELINE` and `TRUSTED_PROXIES`. **Why.** The schema is the list of what the deploy must provide. A missing or malformed server secret fails validation when the server environment loads. The runtime switches in `@template-cloudflare/env/runtime-flags` are read on every call instead: an invalid value never becomes a different setting, but falls back to the safe behavior and is logged once per isolate ([runtime switches](/docs/development/environments#runtime-switches)). **Anti-pattern.** `process.env.SOMETHING` in a feature. **Enforcement.** `[lint]` `node/no-process-env` for `packages/**` in `.oxlintrc.json` and for `apps/web/src` in its nested config. `NODE_ENV` and `EXPO_OS` are allowed because bundlers replace them. Named exceptions: `packages/env/**`, tests and `packages/api/src/testing/**`, CLIs (`packages/*/scripts/**`, `project-setup-cli`, `db/src/seed.ts`) and the test-only provider switch in `assistant.provider.ts`. Runtime switches go through `@template-cloudflare/env/runtime-flags`, which reads them on every call. ## Logs separate expected from unexpected [#logs-separate-expected-from-unexpected] **Rule.** An expected failure (a mapped 4xx) is not logged as an error; the server error handler writes it at debug level. An unexpected failure (a defect, a 500) is logged once by the server error handler, with the request ID that the response also returns in `x-request-id`. Logs carry identifiers, never credentials, prompts or private payloads. **Why.** Error logs then mean something needs attention, and a user can quote one ID that leads to the log line. **Anti-pattern.** `console.error` in a service for a `NOT_FOUND`. **Enforcement.** `[test]` the server error handler tests · `[convention]` for payload content. ## Lists page one way per endpoint [#lists-page-one-way-per-endpoint] **Rule.** Use the pagination rule in [oRPC conventions](/docs/features/api#pagination). Use a cursor for lists that grow without bound. Use offset only where the interface needs a total or page numbers. Every query has a total order with an `id` tie-breaker. **Enforcement.** `[test]` list tests · `[convention]` the choice of strategy. ## Key files [#key-files] - [`packages/api/src/platform/rpc/procedures.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/rpc/procedures.ts) - [`packages/api/src/platform/rpc/procedure-edge.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/rpc/procedure-edge.ts) - [`packages/api/src/platform/effect/db.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/effect/db.ts) - [`packages/api/src/features/todo/todo.service.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/todo/todo.service.ts) - [`packages/api/src/features/documents/documents.errors.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/documents/documents.errors.ts) - [`.oxlintrc.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/.oxlintrc.json) # Conventions Use the existing owners and enforce boundaries in code and tooling. ## Hooks and components [#hooks-and-components] Hooks coordinate asynchronous state, mutations, subscriptions, error presentation and navigation. Components render that state and compose primitives. A small static component does not need an artificial hook; a screen should not own a second session authority. ## Module boundaries [#module-boundaries] Backend rules (feature shape, errors, Effect, tenancy, configuration, logging) are on [Backend conventions](/docs/development/backend-conventions), each with its reason and enforcement. Before opening a pull request, use the [review checklist](/docs/development/review-checklist). - No new barrels on server-capable modules. Use safe leaves for values needed by clients. - No dynamic `import()` in new implementation. Use static imports and framework-supported build splitting. - Enumerate cross-package exports deliberately. - Do not use `window.confirm`; use the shared accessible confirmation dialog. - Validate external payloads once at their boundary with Zod. Keep internal state typed. - Preserve Organization scope, current authorization and transactional audit requirements. ## UI and language [#ui-and-language] Read `apps/web/PRODUCT.md` before UI work. shadcn is the visual authority; neutral semantic tokens make rebranding inexpensive. Use existing Base UI primitives and the component catalog. Authenticated screens follow the product’s operating interface; marketing has separate design guidance. Add chrome strings to English, German and French catalogs together. Preserve keyboard navigation, visible focus, reduced-motion preferences and meaningful labels. Use semantic links for destinations and buttons for actions. ## Changes and evidence [#changes-and-evidence] Use logical Conventional Commits. Keep generated migration SQL and relevant tests with their feature. Record measurements for runtime or bundle changes, including exact commands and limitations. Read the relevant ADR before altering an established boundary; historical measurements describe their captured revision, not a guarantee about the current tree. ## Key files [#key-files] - [`AGENTS.md`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/AGENTS.md) - [`apps/web/PRODUCT.md`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/PRODUCT.md) - [`packages/config/oxlint-client-boundary.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/config/oxlint-client-boundary.json) # Writing documentation Author repository MDX, reuse architectural decisions and curate release notes. ## Add a page [#add-a-page] Create `apps/web/content/docs/section/page.mdx` with `title` and `description` frontmatter. Add its slug to that section’s `meta.json` order. Link to canonical `/docs/...` paths. Use fenced code with a language for build-time highlighting. ```md --- title: "A useful title" description: "The decision or task this page explains." --- ## Start with the task Explain the owner, the change and how to verify it. ``` Supported components include `Callout`, `Steps`/`Step`, `Tabs`/`Tab` (supply a unique `id`), `Cards`/`Card`, `FileTree` and `Diagram`. Tabs use native radio inputs so server-rendered articles remain keyboard-operable without article hydration. ## One source of truth [#one-source-of-truth] ADRs and the agent connection guide are imported directly from `docs/`, not copied. Relative repository links are rewritten at compile time. Processed Markdown powers the copy button, `.md` routes and agent discovery. English content remains English when navigation changes language. The collection is synchronous and Vite imports compiled MDX eagerly on the server. Client route data contains rendered public HTML and navigation metadata. The browser does not import the article modules or a Shiki highlighter. Search uses Fumadocs’ server index at `/api/docs/search`. ## Release notes [#release-notes] Add one dated MDX file under `content/docs/changelog/YYYY-MM-DD-slug.mdx` with title, description and an ISO date string. Describe observable changes and link to their guides and commits. The newest three entries render in full; older entries remain readable individually. The RSS feed is `/docs/changelog/rss.xml`. ```sh node scripts/changelog-draft.mjs ``` The script prints a draft from merges and Conventional Commits since the latest entry date. A maintainer chooses what belongs in the release, checks dates and edits the prose before saving it. Historical entries are curated groups of changes, not claims of tagged semantic versions. ## Diagrams [#diagrams] Use the diagram-design workflow, keep its HTML sources and export static SVG. Docs diagrams use the app’s neutral palette and font. Local scrolling preserves readable labels on narrow screens. Update both theme exports and their descriptive text when the underlying flow changes. ## Key files [#key-files] - [`apps/web/source.config.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/source.config.ts) - [`apps/web/src/lib/docs/source.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/lib/docs/source.ts) - [`apps/web/src/components/docs/mdx-components.tsx`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/components/docs/mdx-components.tsx) # Enforcement Which rule is checked by what: type checks, lint, tests, build and deploy gates, scripts, and the rules that still rely on review. A rule that nothing checks decays. This page lists the repository's rules, where each one is stated and what fails when it is broken. Use it to decide how much to trust a rule, and to find the next rule that should get a check. ## Levels [#levels] | Level | Meaning | | --- | --- | | `[type]` | `pnpm check-types` rejects the violation. | | `[lint]` | `pnpm lint` (oxlint plus repository checks) rejects it. | | `[test]` | A test in `pnpm test`, the integration suite or the RLS matrix fails. | | `[build]` | `pnpm --filter web build` fails. | | `[deploy]` | A preview or production workflow refuses to publish. | | `[tool]` | A repository script refuses the action. | | `[manual]` | A verification script exists, but you run it yourself; CI does not. | | `[convention]` | Only review holds it. These are the fragile ones. | ## Coverage [#coverage] | Rule | Stated in | Enforcement | | --- | --- | --- | | Clients import leaves, contracts or types only | [ADR-0002](/docs/adr/0002-no-barrels-on-server-capable-modules) | `[lint]` `packages/config/oxlint-client-boundary.json` (server-only specifiers, including `@template-cloudflare/api/mcp/*`), extended by `apps/web/src`, `apps/mobile/src`, `packages/ui/src` · `[test]` `scripts/leaf-modules.test.mjs` (D1) · `[build]` `apps/web/scripts/assert-client-bundle-clean.mjs` | | A feature contract carries the `LEAF MODULE` header | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[test]` `scripts/leaf-modules.test.mjs` | | Package exports are enumerated and documented | [ADR-0002](/docs/adr/0002-no-barrels-on-server-capable-modules), [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[test]` `packages/api/src/exports.test.ts` · `[test]` the package-exports check in `apps/web/src/lib/docs/source.test.ts` | | Mobile aliases point at the canonical file; the web app does not use them | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[test]` `packages/api/src/exports.test.ts` (a test, not lint: see the note below) | | Only the app router and the tool catalog import `*.procedures` (D3) | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[lint]` `.oxlintrc.json`, override for `packages/api/src/**` | | Only the job runner imports `*.jobs` (D4) | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[lint]` `.oxlintrc.json`, override for `packages/api/src/**` | | `platform/**` does not import features, except `admin.roles` and `admin.api` (D5) | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[lint]` `.oxlintrc.json`, override for `packages/api/src/platform/**` | | Features import each other through public roles or declared edges (D6) | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[test]` `scripts/feature-boundaries.test.mjs` | | No import cycles in `packages/api/src` and `packages/db/src` (D7) | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[test]` `scripts/import-cycles.test.mjs`, run by `pnpm test` | | Migrated procedures hold no queries (D8) | [ADR-0011](/docs/adr/0011-backend-feature-shape) | `[lint]` `.oxlintrc.json`, override listing `todo` and `documents` procedures: refuses `drizzle-orm`, the schema barrel `@template-cloudflare/db/schema` and `@template-cloudflare/db/schema/
` · `[convention]` `context.db.query.*`, which needs no import | | Tenant data only through `context.db` | [ADR-0009](/docs/adr/0009-row-level-security) | `[test]` `packages/api/src/platform/rpc/rls.isolation.test.ts`, `packages/db/src/rls-catalog.test.ts`, `test:rls-matrix` (CI job `rls-matrix`) | | RLS survives restores and host moves | [ADR-0009](/docs/adr/0009-row-level-security) | `[deploy]` `db:verify-rls` at the end of `db:apply-pending` | | Cross-tenant paths are listed | [ADR-0009](/docs/adr/0009-row-level-security) | `[lint]` `scripts/check-system-call-sites.mjs` against `packages/db/src/system-call-sites.txt`; pool-import restriction in `.oxlintrc.json` | | No network I/O inside `withTenant` | `AGENTS.md` | `[convention]` | | Isolation holds on a real deploy | [ADR-0009](/docs/adr/0009-row-level-security) | `[manual]` `scripts/rls-preview-proof.mjs` against the preview | | Hyperdrive query caching stays off | [ADR-0009](/docs/adr/0009-row-level-security) | `[deploy]` `packages/project-setup-cli/src/hyperdrive.mjs check` in `.github/workflows/deploy-prod.yml` | | `TENANCY_PROBE` only on previews | [ADR-0009](/docs/adr/0009-row-level-security) | `[deploy]` `scripts/check-production-config.mjs` | | Domain failures are tagged and mapped once | [ADR-0010](/docs/adr/0010-procedures-run-one-effect-program) | `[type]` the `satisfies never` mapper in each `.errors.ts` · `[lint]` D2 in `.oxlintrc.json`: no `@orpc/*` in services, rules and policies of migrated features · `[test]` `packages/api/src/testing/characterization/*.client-errors.test.ts` | | Every error code has a message in every language | [error handling](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/error-handling.md) | `[type]` `satisfies` in `packages/errors/src/keys.ts` · `[test]` `packages/errors/src/locales.test.ts`, `apps/web/src/locales/locales.test.ts` | | Effect never reaches a client | [ADR-0005](/docs/adr/0005-effect-boundary) | `[build]` `apps/web/scripts/assert-client-bundle-clean.mjs` | | A procedure runs one program; a client abort after it starts does not roll back a write; database work drains | [ADR-0010](/docs/adr/0010-procedures-run-one-effect-program) | `[test]` `todo.abort.test.ts`, `documents.abort.test.ts`, `packages/api/src/platform/rpc/procedure-edge.test.ts`, `packages/api/src/platform/effect/db.test.ts` | | One `run*` per entry point, no module-scope I/O runtime | [ADR-0005](/docs/adr/0005-effect-boundary), [ADR-0010](/docs/adr/0010-procedures-run-one-effect-program) | `[convention]` | | Only idempotent reads retry | [ADR-0005](/docs/adr/0005-effect-boundary) | `[test]` `packages/api/src/platform/effect/retry.test.ts` · `[convention]` for new call sites | | Deadlines use production values | [ADR-0005](/docs/adr/0005-effect-boundary) | `[test]` `@effect/vitest` with `TestClock`, e.g. `documents.jobs.test.ts`, `imports.jobs.test.ts` | | Queue persistence changes pass the fault gate | [ADR-0006](/docs/adr/0006-effect-persisted-queue) | `[manual]` `scripts/test-persisted-queue-spike.mjs` | | Configuration is declared in `packages/env` | [Backend conventions](/docs/development/backend-conventions) | `[lint]` `node/no-process-env` in `.oxlintrc.json` (`packages/**`) and `apps/web/src/.oxlintrc.json`, with named exceptions | | Runtime switches fail safe on an invalid value | [Environment and secrets](/docs/development/environments#runtime-switches) | `[test]` `packages/env/src/runtime-flags.test.ts` | | Every RPC response carries `x-request-id`, and the error log records the ID | [Error handling](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/error-handling.md) | `[test]` `packages/api/src/platform/rpc/request-id.test.ts`, `apps/web/src/routes/api/rpc/$.test.ts` | | Input validation answers HTTP 400 with `INPUT_VALIDATION` | [Error handling](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/error-handling.md) | `[test]` `packages/errors/src/server.test.ts` (each `APP_ERRORS` status in `errorStatusMap`), `apps/web/src/routes/api/rpc/$.test.ts` (400 on the wire), characterization tests | | Org role checks fail closed and only follow `orgProcedure` | [Backend conventions](/docs/development/backend-conventions) | `[type]` the middleware's input context requires `organizationRole` · `[test]` `packages/api/src/platform/rpc/require-org-role.test.ts` (including `@ts-expect-error` on other procedures) · `[convention]` where it is applied | | Expected failures are not logged as errors | [Backend conventions](/docs/development/backend-conventions) | `[test]` `packages/errors/src/server.test.ts` | | Schema changes are migrations, never pushes | [Database](/docs/features/database) | `[tool]` `db:push` runs `packages/db/scripts/refuse-push.mjs` · `[test]` CI applies migrations twice | | Optional features can be removed cleanly | [Optional features](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/optional-features.md) | `[manual]` `scripts/verify-optional-client-removal.mjs` | | Dependencies respect the release quarantine and exact pins | [Library reference](/docs/libraries) | `[test]` `scripts/tooling-tests.mjs` | | Formatting | [Testing and verification](/docs/development/testing) | `[lint]` `pnpm format:check` (`scripts/check-format.mjs`) | | Docs links, ADR pages and package exports pages are valid | [Writing documentation](/docs/development/docs) | `[test]` `apps/web/src/lib/docs/source.test.ts` | | Runtime and bundle changes are measured | [Conventions](/docs/development/conventions) | `[convention]` | | Pagination strategy per endpoint | [Backend conventions](/docs/development/backend-conventions) | `[convention]` | ## Notes on the lint configuration [#notes-on-the-lint-configuration] - **An oxlint override replaces a rule's options; it does not merge them.** Every `no-restricted-imports` override in `.oxlintrc.json` therefore repeats the system-pool paths of the base rule, and the most specific files come last. A ban added in one override disappears for files a later override matches unless it is repeated there. For the same reason, the web app's ban on the mobile aliases is a test in `packages/api/src/exports.test.ts`: as a lint rule it would have to repeat the whole client boundary inside `apps/web/src/.oxlintrc.json`. - **D2 and D8 list the migrated features by path.** A feature that moves to tagged errors and an Effect service adds its `*.service.ts`, `*.rules.ts`, `*.policy.ts` and `*.procedures.ts` to those overrides in the same change. The comment above D2 names the features still to migrate; that list only shrinks. - **Import cycles are a test, not a lint rule.** `import/no-cycle` needs oxlint's `import` plugin, whose module graph made `pnpm lint` about 3.7 times slower. `scripts/import-cycles.test.mjs` runs the rule on the two server trees inside `pnpm test`. - **Nothing in the root overrides matches `apps/mobile`**, whose lint output stays unchanged. ## Next rules to automate [#next-rules-to-automate] - **No network I/O inside `withTenant`.** A test could wrap the tenant handle and fail when a `fetch` starts inside the callback. - **One `run*` per entry point.** A lint pattern could refuse `Effect.run*` outside `platform/rpc/procedure-edge.ts`, `platform/effect/edge.ts` and the job runner. - **Tenant isolation on a deploy.** `rls-preview-proof.mjs` could run in the preview workflow after the runtime check. - **Optional removal.** The six removal runs could become a scheduled CI job. They currently stop on `main` at a stale recipe (`apps/mobile/src/app/(tabs)/(profile)/profile.tsx` no longer matches), so the recipes need repair first. # Environment and secrets Keep Node, Worker and native configuration aligned without exposing credentials. | Surface | Local source | Visibility | | --- | --- | --- | | Node scripts and web development | `apps/web/.env` | Server variables stay server-only | | Local workerd | `apps/web/.dev.vars` and Wrangler bindings | Worker configuration | | Expo | `apps/mobile/.env` | EXPO_PUBLIC_ values are bundled into the app | | Production/preview | Worker secrets, bindings and workflow inputs | Server secrets must not become build-time public values | `packages/env` validates each boundary separately. Web public configuration includes `VITE_SITE_URL`; native uses `EXPO_PUBLIC_SERVER_URL`. Never put credentials under public prefixes. Do not paste complete environment files into logs, issues or documentation. ## Database and storage [#database-and-storage] Node uses `DATABASE_URL`. Production workerd uses Hyperdrive; its connection string belongs to the current invocation. Keep Hyperdrive caching disabled. Local built-Worker tests can override the Hyperdrive connection string with an isolated password-bearing local URL. R2 credentials, bucket configuration and files gateway settings must describe the same environment. Public file URLs are an application concern; private objects still require authorization through the gateway. ## Runtime switches [#runtime-switches] Four deployment switches are declared in `packages/env/src/runtime-flags.ts` and read on every call, never cached, so changing one on the Worker takes effect without a code change. Unset and empty mean the default. A value that does not parse never becomes a different setting: the reader keeps the safe behavior and logs `[env] = is invalid; …` once per isolate. | Variable | Default | Accepted values | Behavior | Invalid value | | --- | --- | --- | --- | --- | | `DB_TENANT_PIPELINE` | pipelined | `0`, `1` | `0` is the kill switch: tenant statements fall back to explicit `BEGIN`/`COMMIT` ([runbook](/docs/development/runbooks#tenant-statements-fail-after-a-database-or-driver-change)). | Pipelining stays on, and the value is logged. | | `TENANCY_PROBE` | off | `0`, `1` | `1` mounts `tenancy.probe` for the RLS preview proof. Previews set it; the production deploy refuses it. | The probe stays unmounted, and the value is logged. | | `TRUSTED_PROXY_HEADER` | unset: the socket address is the client IP | a header name, such as `x-forwarded-for` | On Node behind your own proxy, the header the proxy appends the client to. Cloudflare Workers always use `cf-connecting-ip` and ignore it. | Proxy headers are ignored (the socket address is used), and the value is logged. | | `TRUSTED_PROXIES` | `1` when `TRUSTED_PROXY_HEADER` is set | a positive integer | How many trusted proxies append to that header; the client is that many entries from the right. | The proxy header is ignored instead of falling back to one hop, and the value is logged. | ## Optional providers [#optional-providers] Payments require provider selection and provider credentials, plus their visibility flags. AI requires provider configuration and `ai.assistant`. Email chooses an available transport in a defined order. A flag alone cannot configure an external account. Run `pnpm setup` to populate or repair configuration and `pnpm doctor` to diagnose it. Keep environment examples descriptive; real values belong in ignored local files or the intended secrets store. ## Key files [#key-files] - [`packages/env/src/server.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/env/src/server.ts) - [`packages/env/src/web.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/env/src/web.ts) - [`packages/env/src/runtime-flags.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/env/src/runtime-flags.ts) - [`packages/env/src/native.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/env/src/native.ts) - [`apps/web/wrangler.jsonc`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/wrangler.jsonc) ## Select the runtime [#select-the-runtime] ```sh # Reads apps/web/.env through the Node configuration path. pnpm dev:web ``` Use the direct development database URL. This runtime does not emulate Worker sockets. ```sh pnpm --filter web build pnpm --filter web exec wrangler dev -c dist/server/wrangler.json --local --port 8790 ``` Supply local Worker variables and an isolated Hyperdrive connection override. This exercises the built workerd artifact. # Review checklist Review or self-review a change against the repository's rules, with the command or test that checks each item. Use this list before opening a pull request and when reviewing one. Skip the sections the change does not touch. Each item names what checks it: `[type]`, `[lint]`, `[test]`, `[build]`, `[deploy]` or `[convention]`. A `[convention]` item is checked only by a reviewer, so read it carefully. ## Always [#always] - [ ] `pnpm check-types`, `pnpm lint`, `FORMAT_BASE_REF=origin/main pnpm format:check` and `pnpm test` pass. `[type]` `[lint]` `[test]` - [ ] Database behavior is tested against the disposable cluster: `node scripts/test-local-postgres.mjs exec pnpm test:integration`. `TEST_DATABASE_URL` never points at a shared or hosted database. `[test]` - [ ] A user-visible change has a changelog entry, and a changed flow updates its guide in the same change. `[convention]` - [ ] Commits are logical Conventional Commits, and file moves are not mixed with behavior changes. `[convention]` ## Backend feature shape [#backend-feature-shape] - [ ] The feature lives in `packages/api/src/features//` with role names (`contract`, `procedures`, `service`, `errors`, `jobs`, `http`) as described in [ADR-0011](/docs/adr/0011-backend-feature-shape). `[test]` boundary test - [ ] The contract file starts with the `LEAF MODULE` header and imports only leaves. `[test]` `scripts/leaf-modules.test.mjs` - [ ] Nothing new imports another feature's internals, a `*.procedures` file or a `*.jobs` file, and `platform/` imports no feature. `[lint]` `.oxlintrc.json` · `[test]` `scripts/feature-boundaries.test.mjs` - [ ] No import cycle was introduced. `[test]` `scripts/import-cycles.test.mjs` - [ ] A migrated feature's `*.service.ts`, `*.rules.ts`, `*.policy.ts` and `*.procedures.ts` are listed in the D2 and D8 overrides of `.oxlintrc.json`. `[lint]` ## Tenant isolation [#tenant-isolation] - [ ] Procedures use `context.db`, which runs as `app_tenant`. The `organizationId` filter is still present. `[test]` `rls.isolation.test.ts` - [ ] A new table uses `pgTable.withRLS` with `tenantPolicy(...)`, gets its GRANT in `packages/db/scripts/rls-baseline.mjs` and ships with a generated, reviewed migration. `drizzle-kit push` is never used. `[test]` `rls-catalog.test.ts` - [ ] A new cross-tenant path uses `asSystem(db, reason, fn)` with a new `SystemReason`, and `node scripts/check-system-call-sites.mjs --write` ran in the same change. `[lint]` - [ ] `withTenant` holds no network I/O. `[convention]` - [ ] An operation limited to some Org roles chains `requireOrgRole(...)` after `orgProcedure`; no service compares role strings. `[test]` `require-org-role.test.ts` · `[convention]` - [ ] A tenancy change ran `pnpm --filter @template-cloudflare/db test:rls-matrix`, and `node scripts/rls-preview-proof.mjs ` passed against the preview before a production deploy. `[test]` `[deploy]` ## Client/server boundary [#clientserver-boundary] - [ ] A client imports only leaves, contracts or `import type`. No barrel was added to a server-capable module ([ADR-0002](/docs/adr/0002-no-barrels-on-server-capable-modules)). `[lint]` - [ ] A new cross-package import added a deliberate `exports` entry, and the package page names it. The web app imports no `routers/*.schema` alias. `[test]` `packages/api/src/exports.test.ts`, docs package-exports test - [ ] `pnpm --filter web build` passes, including `assert-client-bundle-clean.mjs`. `[build]` - [ ] `apps/mobile` changed only if the change is meant for the native app. `[convention]` ## Errors and Effect [#errors-and-effect] - [ ] A domain failure is a `Schema.TaggedError` in `.errors.ts`, and the feature mapper handles it. A service imports no `@orpc/*`. `[type]` `[lint]` - [ ] A new client-visible code is in `APP_ERRORS`, `ERROR_MESSAGE_KEYS` and every locale (en, de, fr). `[type]` `[test]` locale tests - [ ] A procedure body calls `runProcedure` once. No `Layer`, `ManagedRuntime` or I/O runtime exists at module scope ([ADR-0010](/docs/adr/0010-procedures-run-one-effect-program), [ADR-0005](/docs/adr/0005-effect-boundary)). `[convention]` - [ ] Only idempotent reads are retried. A webhook POST or an email send is never retried in-round. `[convention]` ## Configuration and logging [#configuration-and-logging] - [ ] A new environment variable is declared in `packages/env` and documented in the environments guide. It is read through the schema, not `process.env`. `[lint]` `node/no-process-env` - [ ] Expected failures (4xx) are not logged as errors. Unexpected failures carry the request ID. Logs contain no credentials, prompts or private payloads. `[convention]` ## Runtime, bundle and decisions [#runtime-bundle-and-decisions] - [ ] A runtime, deadline or heavy-dependency change has a dated report in `docs/measurements/` with exact commands and limits. `[convention]` - [ ] A change to an established boundary reads the relevant ADR first. A new boundary decision gets an ADR with a status line, and the ADR index is updated. `[convention]` - [ ] Dependency changes follow the two-day release quarantine and exact pins. `[test]` tooling tests Read [Enforcement](/docs/development/enforcement) for the full list of rules and how each is checked. # Runbooks Symptom, command and verification for the operational procedures the template supports. Each runbook names a symptom, the commands to run and how to confirm the result. The reasons live in the linked decisions; this page lists only the steps. Run database commands against the environment you mean, and never against a database you need without a backup. ## Database restored or moved to another host [#database-restored-or-moved-to-another-host] **Symptom.** After a `pg_restore` or a provider move, tenant requests see no rows, fail with permission errors, or `db:apply-pending` reports RLS problems. A restore with `--no-owner --no-acl` loses grants, and loses policies when the `app_tenant` role is missing. If the target connection role cannot create roles, have an administrator create `app_tenant` and grant it once: ```sql CREATE ROLE app_tenant NOLOGIN NOINHERIT NOBYPASSRLS; GRANT app_tenant TO WITH INHERIT FALSE, SET TRUE; ``` Restore the dump, then run the pending migrations against the target. The command ends with the RLS verification and repairs its idempotent part: ```sh pnpm --filter @template-cloudflare/db db:apply-pending ``` Check, and repair what is left if needed: ```sh pnpm --filter @template-cloudflare/db db:verify-rls pnpm --filter @template-cloudflare/db db:verify-rls --repair ``` **Verify.** `db:verify-rls` exits 0, and `select pg_has_role(current_user, 'app_tenant', 'SET')` is true on the runtime connection. If the runtime role differs from the migration role, add it to `app.session_roles`; `db:verify-rls` says so. Reason: [ADR-0009](/docs/adr/0009-row-level-security). ## Tenant statements fail after a database or driver change [#tenant-statements-fail-after-a-database-or-driver-change] **Symptom.** Tenant queries error or time out after a PostgreSQL, Hyperdrive or `pg` change, while system paths (Better Auth, cron) work. Turn off statement pipelining without a code change. Set the Worker variable or secret `DB_TENANT_PIPELINE=0`. Tenant statements then use explicit `BEGIN`, settings, statement and `COMMIT`. Redeploy or wait for the variable to apply, and retry the failing request. Reproduce locally with both modes before turning pipelining back on: ```sh pnpm --filter @template-cloudflare/db test:rls-matrix ``` **Verify.** The failing request succeeds with the switch set. `test:rls-matrix` passes in both modes before you remove the variable. ## Hyperdrive query caching is on [#hyperdrive-query-caching-is-on] **Symptom.** The production deploy stops at the Hyperdrive check, or a Hyperdrive was created without `pnpm setup`. With caching on, reads are keyed by query text and parameters and can cross the tenant boundary. ```sh node packages/project-setup-cli/src/hyperdrive.mjs check pnpm setup --hyperdrive-id pnpm doctor --connect ``` **Verify.** The check exits 0 and `pnpm doctor --connect` reports caching disabled. Reason: [ADR-0009](/docs/adr/0009-row-level-security). ## Prove tenant isolation on a deploy [#prove-tenant-isolation-on-a-deploy] **When.** Before a production deploy that touches tenancy, the database driver or Hyperdrive. ```sh node scripts/rls-preview-proof.mjs ``` Run it against the pull request's preview: the `tenancy.probe` procedure exists only where `TENANCY_PROBE=1`, which is set for previews and refused in the production configuration. When sign-up needs email verification, pass two verified accounts with `RLS_PROOF_USER_A=email:password` and `RLS_PROOF_USER_B=email:password`. **Verify.** Exit 0 means no leak. Exit 1 is a leak or a broken guarantee, so do not deploy. Exit 2 means setup failed (for example, verification required). ## Take production offline [#take-production-offline] **Symptom.** Production must stop serving traffic now. Run the **Emergency shutdown (wrangler worker)** workflow with `action=shutdown` and `confirm=yes`. Run it again with `action=restore` to bring it back. The workflow toggles the Worker's `workers.dev` subdomain only. Production is served from a custom domain (`routes` in `apps/web/wrangler.jsonc`), which the workflow does not block. To stop traffic on the custom domain, also disable the route or add a WAF rule that blocks it in the Cloudflare dashboard, and remove it afterwards. **Verify.** Request the custom domain and the `workers.dev` URL. Both must refuse traffic during the shutdown, and both must serve again after the restore. ## Roll back a Worker deploy [#roll-back-a-worker-deploy] Follow [Deployment and rollback](/docs/deploy#migrations-and-rollback). A Worker rollback does not reverse migrations, restore deleted records or undo external deliveries. ## After an incident [#after-an-incident] Write a short note in the issue or pull request that closed it, with three headings: - **What is back**: the service, the data and the time it returned. - **What is lost**: rows, deliveries or files that could not be recovered, with evidence. - **Verdict**: recovered, partly recovered or not recovered. Add follow-up issues. ## Key files [#key-files] - [`.github/workflows/emergency-shutdown.yml`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/.github/workflows/emergency-shutdown.yml) - [`packages/db/scripts/verify-rls.mjs`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/db/scripts/verify-rls.mjs) - [`scripts/rls-preview-proof.mjs`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/scripts/rls-preview-proof.mjs) - [`packages/project-setup-cli/src/hyperdrive.mjs`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/project-setup-cli/src/hyperdrive.mjs) # Workspace scripts Run the repository’s existing entry points from the workspace root. | Command | Purpose | | --- | --- | | `pnpm setup` / `pnpm doctor` | Configure or diagnose the project | | `pnpm dev` | Start workspace development tasks | | `pnpm dev:web` / `pnpm dev:mobile` | Start one application | | `pnpm build` | Build workspace targets | | `pnpm check-types` | Run workspace type checks | | `pnpm lint` | Repository source checks | | `pnpm exec oxlint` | Oxlint, including import boundaries | | `pnpm format:check` / `pnpm format` | Check changed files / format | | `pnpm test` | Unit suites across workspaces | | `pnpm test:integration` | Isolated local Postgres integration suite | | `pnpm db:generate` / `pnpm db:migrate` | Generate / apply reviewed migrations | | `pnpm db:studio` | Open database inspection tooling | | `pnpm jobs:run` | Run scheduled work manually against configured data | | `pnpm update:js` | Guarded JavaScript dependency updater | | `pnpm update:native` | Expo compatibility repair; review and rebuild afterward | | `pnpm deploy` | Explicit deployment to the configured Worker | `db:push` deliberately refuses execution. Jobs and deployment commands can change configured remote state; use a disposable environment for experiments. See [testing](/docs/development/testing) for isolated commands. Turborepo coordinates workspace tasks. Before changing its configuration, resolve the installed `turbo` package and read its bundled `docs/README.md` and relevant task documentation; behavior is version-dependent. ## Key files [#key-files] - [`package.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/package.json) - [`turbo.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/turbo.json) - [`scripts/update-dependencies.mjs`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/scripts/update-dependencies.mjs) # Local setup Configure a clone without overwriting an existing project’s identity or credentials. ## Prerequisites [#prerequisites] Use the repository’s pnpm version (`packageManager` in the root manifest), a supported Node runtime (CI uses Node 24), Git and a local Postgres installation for integration tests. Native development additionally needs the platform toolchain and a compatible Expo development client. ```sh pnpm install --frozen-lockfile ``` ```sh pnpm setup pnpm doctor ``` Setup covers identity, environment files and the selected database/Cloudflare providers. For an existing clone, preserve its identity and credentials and repair only missing configuration. ```sh pnpm db:migrate pnpm dev:web # In another terminal, when needed: pnpm dev:mobile ``` ## Local PostgreSQL [#local-postgresql] Any PostgreSQL 16 or newer works; Neon is only one host. To develop without a hosted database, run one in Docker (or Homebrew) and connect as a table owner that can create roles: ```sh docker run -d --name template-postgres -e POSTGRES_PASSWORD=postgres -p 127.0.0.1:5432:5432 postgres:17 docker exec template-postgres psql -U postgres \ -c "CREATE ROLE template_owner LOGIN CREATEROLE CREATEDB PASSWORD 'template'" \ -c "CREATE DATABASE template_dev OWNER template_owner" ``` Set `DATABASE_URL=postgresql://template_owner:template@127.0.0.1:5432/template_dev` in `apps/web/.env` (and `.dev.vars` for `wrangler dev`), then `pnpm db:migrate`. Put it in the env file rather than the shell: `pnpm run dev` goes through Turbo, which does not pass undeclared shell variables to the dev server. The first migration creates the `app_tenant` role that row-level security switches to; connecting as the stock `postgres` superuser also works. If your role cannot create roles, the migration prints the two statements an administrator must run once. For the built Worker, Hyperdrive needs a local override with a password, e.g. `CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE=postgresql://template_owner:template@127.0.0.1:5432/template_dev`, or `localConnectionString` on the `hyperdrive` entry of an uncommitted Wrangler config. Row-level security behaves the same with and without Hyperdrive; see [tenant isolation](/docs/features/database#tenant-isolation). ## Local runtime [#local-runtime] The web dev server defaults to port 3005 and uses Nitro/Node, not workerd. The production build uses the Cloudflare plugin. A successful dev request alone does not validate Worker compatibility. Use the [built-Worker checks](/docs/development/testing) for changes to server dependencies. Parallel worktrees need separate ports and isolated databases. Set an explicit dev-server port, adjust the public server URL used by mobile, and stop only processes you started. Do not run migrations against another lane’s database. ## Common setup failures [#common-setup-failures] Missing credentials belong in the correct [environment file](/docs/development/environments). A stale development client needs rebuilding after native dependency changes. A database history mismatch needs investigation, not `db:push`. Start with `pnpm doctor` and the existing setup skill before manually rewriting project identifiers. ## Key files [#key-files] - [`packages/project-setup-cli/src/index.mjs`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/project-setup-cli/src/index.mjs) - [`.agents/skills/setup-envs/SKILL.md`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/.agents/skills/setup-envs/SKILL.md) - [`.agents/skills/startup/SKILL.md`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/.agents/skills/startup/SKILL.md) # Testing and verification Verify domain behavior against real Postgres and the deployed runtime shape. ## Fast gates [#fast-gates] ```sh pnpm install --frozen-lockfile pnpm check-types pnpm lint pnpm exec oxlint FORMAT_BASE_REF=origin/main pnpm format:check pnpm test pnpm --filter mobile exec tsc --noEmit ``` Type checks do not validate Worker bundling or database behavior. The format check compares changed files to its base reference; use the intended review base. ## Isolated Postgres [#isolated-postgres] ```sh node scripts/test-local-postgres.mjs exec pnpm test:integration node scripts/test-local-postgres.mjs exec node scripts/test-local-jobs.mjs ``` The wrapper creates a disposable local cluster, exports the owned test database variables, applies migrations and stops its cluster when the child finishes. It refuses unsafe assumptions about a remote/shared test database. The jobs harness runs the built Worker with controlled provider fixtures and exercises persisted outcomes, timeouts, leases and repeated ticks. Effect timeout unit tests use `@effect/vitest` with `TestClock` and production durations. Queue persistence changes additionally require the fault gate in [ADR-0006](/docs/adr/0006-effect-persisted-queue). ## What to test [#what-to-test] Test the decisions your change makes, not the framework, the ORM or a getter. Database tests run against the disposable cluster above. **Every procedure:** - one success with the documented output; - one `INPUT_VALIDATION` rejection (HTTP 400 on the wire; `BAD_REQUEST` through an in-process `createRouterClient`); - a caller who is not a Member of the Active Organization gets `FORBIDDEN`; - another Organization's row is a `NOT_FOUND`, never a result (use the `two-tenants` fixture in `packages/api/src/testing/two-tenants.ts`); - each tagged error the service can raise, asserting the code and message the client sees (the pilots pin theirs through the real RPC handler in `packages/api/src/testing/characterization/`); - an audited write records its Audit Event in the same transaction. **Every new table or tenant path:** the RLS catalog test passes, and the change is proved on every role profile with `pnpm --filter @template-cloudflare/db test:rls-matrix`. **Every job:** a replay of the same work is a no-op, a lease held by another consumer is not overwritten, and a deadline uses the production value with `TestClock`. **Every list:** the order is stable with equal sort values, the limit is capped, and a malformed cursor is rejected as input. ## Built Worker [#built-worker] ```sh pnpm --filter web build # Run from apps/web in an isolated local test environment: pnpm exec wrangler dev -c dist/server/wrangler.json --local --port 8790 ``` Provide the Hyperdrive local override for the configured binding ID with a password-bearing URL such as `postgresql://template_owner:local@127.0.0.1:PORT/template_test` (the table owner `pnpm test:integration` creates). The exact variable name comes from the Wrangler binding. Keep local tests local; do not deploy to discover a bundling failure. The build checks that client assets contain no server-only markers and that production AI code does not gain the server-function manifest dependency. Runtime smoke checks should include a public page, the changed API, auth refusal paths and discovery endpoints. ## Docs checks [#docs-checks] The web unit suite loads compiled MDX, validates metadata and links, orders releases, and checks Markdown/discovery output. Browser review covers light/dark, desktop/mobile, keyboard focus, local diagram scrolling, code blocks and the command palette. Bundle reports separate total client assets from the initial route graph. ## Key files [#key-files] - [`scripts/test-local-postgres.mjs`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/scripts/test-local-postgres.mjs) - [`scripts/test-local-jobs.mjs`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/scripts/test-local-jobs.mjs) - [`apps/web/scripts/assert-client-bundle-clean.mjs`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/scripts/assert-client-bundle-clean.mjs) # Administration Current platform authority protects User management, audits, flags and diagnostics. ## What an Admin can do [#what-an-admin-can-do] The admin area manages Users, bans, roles, Sessions and passwords, and reads the Audit Log. It also manages feature flags and app-update configuration and exposes diagnostics. The platform User role is distinct from an Org role. Navigation uses the observed Session for presentation. Server operations use `adminProcedure` or `adminWriteProcedure` and re-read current authority. A stale sidebar, a demotion or an impersonation cannot preserve old admin permissions. ## Audited changes [#audited-changes] The admin capability dispatches Better Auth operations inside the existing transaction and audit coordination. Keep those mutations behind that capability instead of writing auth tables directly. Per-User timelines and the global Audit Log expose different authorized views of the same security history. Impersonation runs with the target User's exact permissions. An admin impersonating a non-admin loses admin access until stopping impersonation. Never recover authority from the `impersonatedBy` field. ## Extend [#extend] Add a schema, server operation and localized hook/view. Test unauthenticated access, ordinary Users, a revoked/demoted Admin and impersonation. Destructive actions use the existing dialog patterns rather than `window.confirm`. Diagnostics must return safe status and never echo credentials. ## Key files [#key-files] - [`packages/api/src/features/admin/admin.procedures.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/admin/admin.procedures.ts) - [`packages/api/src/features/admin/admin.api.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/admin/admin.api.ts) - [`packages/auth/src/admin-audit.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/auth/src/admin-audit.ts) - [`packages/api/src/features/audit/audit.procedures.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/audit/audit.procedures.ts) - [`apps/web/src/hooks/use-app-nav.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/hooks/use-app-nav.ts) # Agent-readable pages Public Markdown and discovery derive from the same content developers read. ## Public representations [#public-representations] `/llms.txt` lists public content and connection endpoints. `/llms-full.txt` includes the landing, legal text, static demo content and every developer page. Each developer page has a `.md` representation, including `/docs.md`, deep pages and changelog entries. ```sh curl http://localhost:3005/docs/features/todo-imports.md curl http://localhost:3005/llms.txt ``` Developer content is English only. Landing/legal Markdown supports `?lang=en`, `?lang=de` and `?lang=fr`. Developer docs URLs enter the sitemap without hreflang alternates. Robots permits docs and excludes authenticated routes. ## Build and rendering [#build-and-rendering] Fumadocs MDX compiles the repository content and processed Markdown at build time. The headless core supplies the page tree and search. Articles render on the server with shared UI tokens; a public GET route supports browser navigation. Runtime code reads no documentation files from disk and does not evaluate MDX source. The existing ADR files and `docs/connect-your-agent.md` are imported into the collection, not copied. Relative references become docs links or repository links during compilation. Shiki highlighting happens during the build with github-light/github-dark tokens. ## Extend [#extend] Add MDX with a title and description under `apps/web/content/docs`, then place it in its folder's `meta.json`. Use ordinary Markdown headings and links. Add a diagram as an accessible static SVG, with its light/dark variants. The source/link tests, sitemap and llms generation pick up the page automatically. These routes contain only curated public content. Never add private Threads, account data or Organization exports to the public collection. Tool authorization remains the responsibility of the authenticated API. ## Key files [#key-files] - [`apps/web/source.config.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/source.config.ts) - [`apps/web/src/lib/docs/source.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/lib/docs/source.ts) - [`apps/web/src/lib/docs/page.server.tsx`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/lib/docs/page.server.tsx) - [`apps/web/src/lib/agent-content.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/lib/agent-content.ts) - [`apps/web/src/routes/sitemap[.]xml.tsx`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/routes/sitemap[.]xml.tsx) # oRPC conventions Share Zod contracts and typed clients while keeping authorization on the server. ## Procedure selection [#procedure-selection] | Procedure | Use | | --- | --- | | publicProcedure | Public boot or health data, with captured-scope checks when supplied | | protectedProcedure | An authenticated User's personal data | | orgProcedure | Shared data in the membership-checked Active Organization | | orgProcedure.use(requireOrgRole(...)) | The same, limited to Members with one of the listed Org roles; available, not applied to any procedure yet | | adminProcedure | Global reads requiring the current platform admin role | | adminWriteProcedure | Admin writes through Better Auth's audited capability | Each feature lives in `packages/api/src/features//`: the leaf contract (`.contract.ts`), the procedures (`.procedures.ts`), the service and its errors. Register the procedures in `platform/rpc/app-router.ts`. Browser and mobile clients use the type-only `api/client` export and the feature's contract subpath (`api//contract`). The [backend conventions](/docs/development/backend-conventions) give the full shape. ## Add an operation [#add-an-operation] 1. Define input and output schemas, including bounded pagination and text limits. 2. Choose the procedure according to the resource owner. 3. Apply Organization scope in every relevant query and mutation. 4. Keep the mutation and Audit Event in one transaction when the operation is audited. 5. Add any cross-package schema export explicitly, then consume it through a hook. ## Pagination [#pagination] Each list endpoint uses one strategy. Choose it by what the interface needs: | Use | When | Shape | Examples | | --- | --- | --- | --- | | Offset | The interface shows a total, page numbers or facet counts over a bounded, slowly changing set | `page` from 1 and `pageSize` capped at 100; the output carries `total` and `pageCount` | `todo.list`, the admin audit table and user list | | Cursor | The list grows without bound or changes while it is read (feeds, timelines, "load more") | an opaque cursor of the stable sort key, such as `{ createdAt, id }`, plus `limit` capped at 100; the output carries the next cursor | Activity Timeline, integration delivery activity, Document Text pages | | Bounded window | The server caps the result by design | a plain array or a fixed window, no paging | Board lanes (`limitPerLane`), the Calendar date window, document search | Rules for every list: - Every paginated query has a total `ORDER BY` with a unique tie-breaker (`id`), so equal sort values cannot repeat or skip rows. - Every limit has a maximum in the contract. No endpoint returns an unbounded page. - A cursor is opaque to clients. A malformed cursor is `INPUT_VALIDATION` (HTTP 400), never a 500. - One endpoint offers one strategy; it does not accept both `page` and `cursor`. The HTTP endpoint is `/api/rpc`. The session-authenticated OpenAPI reference is at `/api/rpc/api-reference`, with its specification at `/api/rpc/api-reference/spec.json`. ## Client state [#client-state] TanStack Query owns server data, query keys and invalidation. Forms use TanStack Form. URL state expresses Todo views, filters and sorting. Components render the hook's state; they do not recreate authentication or authorization decisions. Validate external payloads at the boundary. Use the shared error vocabulary and translated resolver rather than returning internal exceptions. Do not create a parallel REST API for a domain operation already represented by oRPC. ## Key files [#key-files] - [`packages/api/src/platform/rpc/procedures.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/rpc/procedures.ts) - [`packages/api/src/platform/rpc/context.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/rpc/context.ts) - [`packages/api/src/features/todo/todo.procedures.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/todo/todo.procedures.ts) - [`packages/api/src/features/todo/todo.service.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/todo/todo.service.ts) - [`packages/api/src/features/todo/todo.contract.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/todo/todo.contract.ts) - [`apps/web/src/utils/orpc.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/utils/orpc.ts) # AI assistant and tools TanStack AI streams a private conversation through one authorized tool catalog. ## Enable and configure [#enable-and-configure] Set `AI_PROVIDER` and its matching secret, then enable `ai.assistant`. Both gates are required. `AI_MODEL` can override the provider default. Secrets remain server-side. The existing MCP server works without a configured AI provider. The default request limits are five model/tool steps, 2,048 output tokens per model call, a 60-second abort deadline and ten requests per minute per User. Input and history are separately bounded. These per-request limits do not replace provider account spending controls. ## One catalog [#one-catalog] `catalog.schema.ts` defines Zod tool contracts; `catalog.ts` binds authorized implementations. The assistant and MCP adapt those same operations. Each call checks current Organization membership. The assistant uses the Session's Active Organization and rejects a change during a turn. The catalog has 15 tools. Read operations include `todo_list`, `todo_detail`, `files_list`, `whoami`, `search`, `fetch`, `document_read` and `organization_info`. Approved writes are `todo_create`, `todo_complete`, `import_start`, `create_todo_draft`, `draft_task_plan` and `propose_review`; `ask_for_input` pauses for a User choice. `files_list` exposes confirmed file IDs and conversion status. `document_read` returns two chunks by default, at most four of 8 KiB each, and `nextCursor` for another page. Search ranks converted text alongside Todos and filenames. Original file bytes never enter tool results. An `awaitingBrowser` result means the User must finish preparation in Files. Reading Document Text sends the selected chunks to the configured model provider and stores the results in the Thread. Treat source text as untrusted data and disclose partial reads. See [documents](/docs/features/documents). Every write needs approval bound to its User, Organization, tool name and parsed arguments. Assistant approvals also bind the Thread and call ID. The execution transaction checks current membership and expiry, applies the change, records its audit and stores the result. A replay returns that result rather than repeating the mutation. ## Streaming and persistence [#streaming-and-persistence] TanStack AI owns the native Promise/SSE lifecycle. The HTTP boundary owns input limits, auth, cancellation, the Thread lease and its bounded pool. Persistence snapshots at most once per second and saves the finished transcript. Stop, timeout or disconnect aborts the turn; reopening recovers the last saved state and pending interrupts. There is no disconnected background run or resumable transport. Text after the last snapshot can be lost. Deleting a Thread deletes its messages and linked approvals but does not undo completed domain changes. ## Extend [#extend] Add a catalog contract and authorized operation once, then adapt through the existing assistant/MCP consumers. Preserve exact-argument approval and replay protection. Tests use the deterministic adapter only in an `ai-test` build plus `AI_TEST_PROVIDER=true`; never publish that build. Read [ADR-0007](/docs/adr/0007-ai-assistant-and-tool-catalog) before changing transport, approvals or persistence. ## Key files [#key-files] - [`packages/api/src/features/agent-tools/agent-tools.catalog.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/agent-tools/agent-tools.catalog.ts) - [`packages/api/src/features/agent-tools/agent-tools.contract.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/agent-tools/agent-tools.contract.ts) - [`packages/api/src/features/assistant/assistant.http.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/assistant/assistant.http.ts) - [`packages/api/src/features/agent-tools/agent-tools.approvals.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/agent-tools/agent-tools.approvals.ts) - [`packages/api/src/features/assistant/assistant.threads.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/assistant/assistant.threads.ts) - [`scripts/test-local-ai.mjs`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/scripts/test-local-ai.mjs) # Authentication and Organizations Better Auth owns identity, sessions, Organizations, platform roles and OAuth. ## Session outcomes [#session-outcomes] Password sign-in can return a Session, a pending two-factor challenge, an unverified email, a Ban or a refusal. Treat those as distinct outcomes. `session-outcome.ts` and `two-factor-outcome.ts` provide the shared interpretation; hooks own navigation and error presentation. The server registers two-factor, admin, organization, passkey, Expo and MCP OAuth plugins. Email verification policy comes from `AUTH_SKIP_EMAIL_VERIFICATION`; setup makes the intended policy explicit. Current server authorization reads the database, with cookie caching disabled. ## Organizations and roles [#organizations-and-roles] Signup provisions a personal Organization. A User may be a Member of several Organizations; the Session selects one Active Organization. Todos and files belong to that Organization. Platform role `admin` grants global administration; Org roles `owner`, `admin` and `member` do not grant platform administration. Membership must be checked at the operation, even if the UI already selected an Organization. An MCP token has no active selection and stays pinned to the User's personal Organization. See [connecting agents](/docs/connect-your-agent). ## Web and native [#web-and-native] Web auth hooks coordinate TanStack Query, forms and redirects. Mobile uses the Better Auth Expo client, secure credential storage and native passkey integration. Its route gate revalidates the Session and hides private content during a transition. Logout, expiry or an authoritative 401 clears private state. A 403 does not automatically sign out an otherwise valid Session. ## Extend safely [#extend-safely] Add auth behavior to the server factory and its tests, then expose the outcome through a hook. Keep email callbacks, membership checks and audit recording in their existing owners. Test revoked Sessions and Organization changes as well as successful login. Never treat an impersonated admin's original identity as permission to access `/admin`. ## Key files [#key-files] - [`packages/auth/src/index.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/auth/src/index.ts) - [`packages/auth/src/session-outcome.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/auth/src/session-outcome.ts) - [`packages/db/src/provision-organization.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/db/src/provision-organization.ts) - [`apps/web/src/hooks/use-sign-in.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/hooks/use-sign-in.ts) - [`apps/mobile/src/hooks/use-session-lifecycle.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/mobile/src/hooks/use-session-lifecycle.ts) # Database and migrations Drizzle manages one Postgres schema through reviewed, journaled SQL migrations. ## Connection ownership [#connection-ownership] `createDb` uses node-postgres and Drizzle relations. Workerd resolves the Hyperdrive binding; Node uses `DATABASE_URL`. Disable Hyperdrive query caching so a recently changed Session, membership or record is not answered from stale query results. Scheduled and streaming work use explicitly owned bounded pools. ## Change the schema [#change-the-schema] ```sh pnpm db:generate # Review the generated SQL and snapshot before applying it. pnpm db:migrate # CI and explicit automation use the same journaled runner: pnpm --filter @template-cloudflare/db db:apply-pending ``` The migration scripts read `apps/web/.env` unless a named environment variable is supplied. They compare the migration history and apply pending SQL. Never use `db:push`; the script deliberately refuses it. Do not edit a migration that has already been applied. An existing database without matching history requires an explicit schema comparison before baseline. `--baseline ` is an operator decision after that comparison, not a generic repair for a failed migration. ## Domain and contracts [#domain-and-contracts] Organization-owned tables carry `organizationId`. Auth tables are owned by Better Auth. Domain enum files are client-safe leaves; Drizzle table definitions are server-only. Add relations to the sanctioned schema aggregate only when the relation builder needs them. Use a transaction for changes that must commit together, including domain writes with outbox/audit records. A retry after an uncertain response needs an idempotency key or a durable result; blindly replaying a write is unsafe. ## Tenant isolation [#tenant-isolation] Every table has row-level security. Procedures, assistant and MCP tools and the files gateway receive `context.db` as the `app_tenant` role for the session's User and Active Organization; each statement runs in a short transaction with `app.user_id` and `app.org_id` set locally, and `app.org_id()` re-checks membership on every statement. Better Auth, the cron tick, billing webhooks and admin run as the connection role through `asSystem(db, reason, fn)`, which is listed in `packages/db/src/system-call-sites.txt` and checked by `pnpm lint`. A new table uses `pgTable.withRLS` with `tenantPolicy(...)` helpers from `schema/tenancy.ts`, plus a GRANT to `app_tenant` in a custom migration. `rls-catalog.test.ts` fails for a table without RLS, a grant without a policy, a policy for another role, or `FORCE ROW LEVEL SECURITY` (which would hide every row from a connection role without BYPASSRLS). PostgreSQL 16 or newer is required. The decision, the supported connection roles and the rejected alternatives are in [ADR-0009](/docs/adr/0009-row-level-security). ```sh pnpm --filter @template-cloudflare/db test:rls-matrix ``` The matrix starts a Docker PostgreSQL 16 and 18 for each connection role (superuser, owner with BYPASSRLS, plain owner, owner without CREATEROLE), applies migrations and runs the catalog and isolation suites. ### Restore or switch providers [#restore-or-switch-providers] The migration journal travels with a dump, but roles do not, and `pg_restore --no-acl` drops grants (and every policy when `app_tenant` is missing on the target). `db:apply-pending` notices: it ends with `db:verify-rls`, repairs the idempotent part (role, `app.session_roles`, functions, grants, missing policies) and fails with the steps to take when it cannot. ```sh # On the target, if the connection role cannot create roles, an administrator runs: # CREATE ROLE app_tenant NOLOGIN NOINHERIT NOBYPASSRLS; # GRANT app_tenant TO WITH INHERIT FALSE, SET TRUE; pg_restore --no-owner --no-acl -d "$TARGET_URL" dump.fc TARGET_URL="$TARGET_URL" pnpm --filter @template-cloudflare/db db:apply-pending TARGET_URL TARGET_URL="$TARGET_URL" pnpm --filter @template-cloudflare/db db:verify-rls TARGET_URL ``` If the Worker connects with a different role than the one that ran the migrations, add it to `app.session_roles` as the migration role; until then tenant requests see no rows. ## Verify against real Postgres [#verify-against-real-postgres] ```sh node scripts/test-local-postgres.mjs exec pnpm test:integration ``` The wrapper creates a disposable local cluster, applies migrations twice and stops its own database. It connects as a plain table owner (`TEMPLATE_TEST_DB_PROFILE=superuser|bypassrls|owner` picks another) and keeps tests away from any hosted database. Use additive migrations for deploys so rolling application code back does not require destructive schema rollback. ## Key files [#key-files] - [`packages/db/src/tenancy.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/db/src/tenancy.ts) - [`packages/db/src/schema/tenancy.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/db/src/schema/tenancy.ts) - [`packages/db/src/connection.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/db/src/connection.ts) - [`packages/db/src/job-db.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/db/src/job-db.ts) - [`packages/db/scripts/apply-pending.mjs`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/db/scripts/apply-pending.mjs) - [`packages/db/scripts/migration-runner.mjs`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/db/scripts/migration-runner.mjs) - [`scripts/test-local-postgres.mjs`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/scripts/test-local-postgres.mjs) # Documents Convert supported files to Document Text in the browser, index it from cron and read it through the shared tool catalog. ## Conversion path [#conversion-path] A confirmed upload of a supported format creates a pending document bound to the source ETag. A dedicated browser Web Worker converts the original with `@firecrawl/anydoc-wasm` 0.2.4, then calls `documents.prepareText`, PUTs the Markdown to `incoming/orgs//.md` and calls `documents.confirmText`. Cron validates and indexes that Markdown; the server never runs AnyDoc. [ADR-0008](/docs/adr/0008-documents-and-large-uploads) records the memory gate the server design failed. Supported inputs are PDF with a text layer, Word, Excel, PowerPoint, OpenDocument, RTF, EPUB, CSV, plain text and Markdown. There is no OCR, external conversion service or streaming parser. The worker is created with `new Worker(new URL(...), { type: "module" })` and fetches WASM only when conversion starts; no dynamic `import()` is involved. ## Limits [#limits] Conversion caps are separate from [upload limits](/docs/features/files). Sources above **1.5 MiB (PDF)** or **10 MiB (other formats)** are stored but not converted. Extracted Markdown is capped at **192 KiB**; plain `.md`/`.txt` files above it show the limit before upload. WASM linear memory is bounded at 32 MiB, and conversion has a 45-second deadline in the browser. The caps live in the leaf module `packages/db/src/schema/documents.enums.ts`. Chunks target 4 KiB and never exceed 8 KiB. `document_read` returns two chunks by default and at most four, plus a `nextCursor`. Cron indexes one document per tick, after the other jobs settle, within a 15-second budget; see [scheduled jobs](/docs/features/scheduled-jobs). ## Failures and recovery [#failures-and-recovery] Encrypted, malformed, unsupported, oversized, resource-limited and OCR-required documents become permanent failures. `needsOcr` means the PDF has no extractable text; the original stays downloadable. `text_too_large` means the Markdown exceeded 192 KiB and will not be indexed. A timeout after 45 seconds, a cancelled conversion or a missing converter asset leaves the document pending and resumable: the file preview can restart preparation from the stored original, which the browser downloads through a byte-capped reader and checks against its ETag. Uploads from external clients, including MCP, report `awaitingBrowser` until a member opens the file in Files and the browser prepares its text. Deleting or changing a source invalidates its text and fences indexing leases before the storage operation. ## Reads and privacy [#reads-and-privacy] Only current Organization members can read chunks. The assistant and MCP use the same `files_list`, `search`, `fetch` and `document_read` operations; see [AI assistant](/docs/features/assistant) and [MCP](/docs/features/mcp). Reading text in the assistant sends the selected chunks to the configured model provider. Document text is untrusted: previews skip raw HTML and remote images, and text cannot grant tool permissions. ## Extend [#extend] Changing the AnyDoc version or raising a cap needs a new measured browser run. Moving conversion back to the server requires passing all three gates in ADR-0008. Keep `prepareText`, the Markdown PUT and `confirmText` together so the server checks ownership, size, UTF-8 and the text ETag. ## Key files [#key-files] - [`packages/db/src/schema/documents.enums.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/db/src/schema/documents.enums.ts) - [`packages/api/src/features/documents/documents.procedures.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/documents/documents.procedures.ts) - [`packages/api/src/features/documents/documents.service.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/documents/documents.service.ts) - [`packages/api/src/features/documents/documents.jobs.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/documents/documents.jobs.ts) - [`packages/api/src/features/documents/documents.contract.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/documents/documents.contract.ts) - [`apps/web/src/lib/document-worker.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/lib/document-worker.ts) - [`apps/web/src/lib/document-conversion.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/lib/document-conversion.ts) - [`patches/@firecrawl__anydoc-wasm@0.2.4.patch`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/patches/@firecrawl__anydoc-wasm@0.2.4.patch) # Email One transport boundary serves verification, recovery, invitations and security notices. ## Transport selection [#transport-selection] The email package selects Cloudflare's Worker `EMAIL` binding first, then Cloudflare REST credentials, then the optional Resend fallback, then development console delivery. Deployment setup uses Cloudflare with an onboarded sender domain. Set `EMAIL_FROM` to the intended display name and address. Node tooling cannot use the Worker binding. Real Node delivery needs `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_EMAIL_API_TOKEN`. A configured Worker binding does not prove Node can send. ## Delivery semantics [#delivery-semantics] `sendEmailEffect` owns the bounded send; its Promise facade serves Better Auth callbacks. There is one send attempt inside the round, with a 10-second timeout. Cloudflare acceptance requires a nonempty message ID. Provider acceptance and inbox receipt are separate checks. Required verification/recovery mail failures must not report a successful auth action. Informational welcome and security notices have their own handling. Never silently turn required production email into console-only delivery. ## Add a template [#add-a-template] Add a pure template in `templates.ts` returning subject, plain text and HTML. Escape user content, pass app branding and URLs explicitly, and call the shared transport. Use a local capture transport in tests; real sends need an explicitly chosen recipient and environment. Inspect [email domain setup](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/email-domain.md) for provider onboarding and the previous deployed preview verification for evidence of actual delivery. ## Key files [#key-files] - [`packages/email/src/transports.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/email/src/transports.ts) - [`packages/email/src/templates.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/email/src/templates.ts) - [`packages/env/src/email.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/env/src/email.ts) - [`packages/auth/src/index.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/auth/src/index.ts) # Feature flags Typed code defaults with database overrides and separate capability gates. ## Declare a flag [#declare-a-flag] `packages/flags/src/registry.ts` declares each key, kind, default value and description. `FlagKey` and `FlagValues` derive from that registry. Unknown database keys are ignored; a missing override falls back to the code default. ```ts "home.greeting": { kind: "boolean", defaultValue: true, description: "Show the personalized greeting on the home screen.", } ``` The evaluator applies platform and targeting context to typed rows. Parsing belongs at the database boundary. Clients consume the safe registry/evaluator exports; `/repo` owns the Drizzle reads and writes and must remain server-only. ## Boot and administration [#boot-and-administration] `appConfig` combines evaluated flags and app-update configuration in one public boot request. Admin flag procedures use the current platform admin guard. Web uses `useFlags`; native boot reads the same server definitions. A visibility flag is not a capability or authorization check. `billing.web` can show navigation only after server providers are configured; `ai.assistant` also requires a model provider. A hidden control never replaces membership or role authorization. ## Extend [#extend] Add the key in code, use its inferred type, and translate any user-facing labels in every catalog. Test default behavior, absent/disabled overrides and invalid stored values. Keep optional features bootable with their flag off and no provider credentials. ## Key files [#key-files] - [`packages/flags/src/registry.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/flags/src/registry.ts) - [`packages/flags/src/evaluate.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/flags/src/evaluate.ts) - [`packages/flags/src/repo.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/flags/src/repo.ts) - [`packages/api/src/features/app-config/app-config.procedures.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/app-config/app-config.procedures.ts) - [`apps/web/src/lib/flags.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/lib/flags.ts) # Files and uploads Send files directly to R2, confirm durable upload intent and recover interrupted multipart uploads. ## Gateway and isolation [#gateway-and-isolation] The web client uses authorized upload procedures for direct byte transport and the files-sdk gateway at `/api/files` for browsing, downloads and deletion. Every operation checks the current Session and membership. The server applies `orgs//`; a filename cannot select or escape an Organization. One private R2 bucket serves all Organizations. Keys preserve the User's filename, including spaces and Unicode, under [ADR-0001](/docs/adr/0001-stored-file-keys-keep-the-user-s-filename). Treat keys as opaque and encode them for URLs. Do not reintroduce filename sanitization that silently changes the display name. ## Durable upload lifecycle [#durable-upload-lifecycle] `uploads.prepare` persists an intent after validating membership, MIME type and size. A signed PUT binds the exact key, size, content type and conditional create. Files above 16 MiB use multipart uploads with 8 MiB parts, at most three concurrent parts and at most three attempts per part. The browser sends `File.slice()` objects and resumes from server-listed parts. Completion validates part lengths and ETags, then checks HEAD metadata before confirming the file. Signed URLs expire after 15 minutes; intents last 24 hours. Cron recovers missing acknowledgements and aborts abandoned multipart sessions. Explicit cancellation aborts multipart; single-PUT cleanup waits 16 minutes to cover a late upload through an issued URL. Images can be up to 100 MiB, documents 1 GiB and ZIP archives 5 GiB. The legacy gateway proxy remains capped at 10 MiB and reauthorizes PUTs through the files-sdk patch. Soft delete is enabled; versioning is not. Text previews stop at 64 KiB, and downloads can use range requests without loading the whole original into the Worker. Confirmed supported files enter the separate [Document Text workflow](/docs/features/documents). Storage limits and conversion limits are independent; a stored original can remain downloadable when text preparation fails. [ADR-0008](/docs/adr/0008-documents-and-large-uploads) explains both paths. ## Configure and extend [#configure-and-extend] Supply `STORAGE_BUCKET`, `STORAGE_ENDPOINT`, `STORAGE_ACCESS_KEY_ID` and `STORAGE_SECRET_ACCESS_KEY` in the intended environment. Setup does not create an R2 bucket. Configure bucket CORS for the actual web origins, upload/read methods and signed headers; expose `ETag` so multipart completion can read it. `apps/web/r2-cors.json` is the starting template, not a list of every worktree or deployed origin. Extend size/MIME rules in storage config and retain admission, confirmation and source-version checks. Keep `files-sdk` on one compatible copy across web, API and storage and rebase its pinned patch on upgrades. Verify upload, download byte equality, anonymous denial, prefix isolation, multipart resume/cancel and interrupted confirmation. ## Key files [#key-files] - [`apps/web/src/routes/api/files/$.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/routes/api/files/$.ts) - [`packages/storage/src/create-r2-files.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/storage/src/create-r2-files.ts) - [`packages/storage/src/upload-plugin.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/storage/src/upload-plugin.ts) - [`packages/api/src/features/uploads/uploads.service.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/uploads/uploads.service.ts) - [`packages/storage/src/direct-s3.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/storage/src/direct-s3.ts) - [`packages/storage/src/config.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/storage/src/config.ts) - [`patches/files-sdk@2.6.2.patch`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/patches/files-sdk@2.6.2.patch) # Internationalization English, German and French UI with request-scoped SSR translation. ## Catalog ownership [#catalog-ownership] Web catalogs live in `apps/web/src/locales/{en,de,fr}.ts`. `MessageKey` derives from English; the other catalogs must retain key and interpolation parity. Shared error translations come from `packages/errors/src/locales`. Mobile maintains its own app catalogs with the same supported languages. Use `useTranslation` in components and hooks. Format dates and numbers with Intl. Do not concatenate translated fragments when the sentence needs different word order. ## Server rendering [#server-rendering] `getSSRLanguage` checks a validated `?lang=` first, then the locale cookie, then the fallback. `getRequestI18n` clones the i18next instance for that request while sharing loaded catalogs. Mutating one global language during SSR would make concurrent requests affect each other. The client keeps its shared instance and updates the document language after a switch. Public localized pages advertise locale alternates. Developer articles are English only: their article region has `lang="en"`, their chrome is translated, and their sitemap entries have no hreflang alternates. ## Extend [#extend] Add a message to English, German and French together. Preserve placeholder names and run the locale parity tests. A new language also needs the supported-language definition, formatters, SEO mappings and native catalogs updated. Translate the domain consistently: a Todo's translated label can differ from its code identifier. ## Key files [#key-files] - [`packages/intl/src/i18n.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/intl/src/i18n.ts) - [`packages/intl/src/server.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/intl/src/server.ts) - [`apps/web/src/lib/intl.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/lib/intl.ts) - [`apps/web/src/locales/en.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/locales/en.ts) - [`apps/mobile/src/lib/i18n/index.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/mobile/src/lib/i18n/index.ts) # Features and flows Trace each feature through contracts, authority, persistence and presentation. - [Authentication and Organizations](/docs/features/auth) - [oRPC conventions](/docs/features/api) - [Database and migrations](/docs/features/database) - [Files and uploads](/docs/features/files) - [Documents](/docs/features/documents) - [Todo imports](/docs/features/todo-imports) - [Scheduled jobs](/docs/features/scheduled-jobs) - [Feature flags](/docs/features/feature-flags) - [Internationalization](/docs/features/i18n) - [Payments](/docs/features/payments) - [Email](/docs/features/email) - [Open Graph images](/docs/features/og-images) - [AI assistant and tools](/docs/features/assistant) - [MCP and agent connections](/docs/features/mcp) - [Agent-readable pages](/docs/features/agent-pages) - [Administration](/docs/features/admin) - [Mobile app](/docs/features/mobile) - [Webhooks and notifications](/docs/features/webhooks) ## Choose the owner first [#choose-the-owner-first] Shared Todos and files belong to an Organization. Sessions and Entitlements belong to a User. A Thread belongs to both one User and one Organization. Follow these owners through the contract, authorization check, transaction and client query key. Each guide names the implementation files, extension path and operational limits. Architectural decisions remain in the [ADRs](/docs/adr). # MCP and agent connections OAuth-authenticated agents use the existing Worker and the shared tool catalog. ## Connection flow [#connection-flow] The MCP resource is `/api/mcp`. Better Auth provides OAuth 2.1, PKCE S256 and dynamic client registration. Protected-resource and authorization-server metadata live at the well-known endpoints; JWKS is `/api/auth/jwks`. Read the [connection guide](/docs/connect-your-agent) for client commands and supported scopes. An unauthenticated request receives a Bearer challenge. `data:read` is required; `data:write` permits requesting writes, each still subject to exact User approval. Read-only grants do not see write tools. Revoking a connection rejects later access even before its JWT expires. ## Scope and approval [#scope-and-approval] MCP is pinned to the User's personal Organization because an OAuth token does not carry the app's Active Organization. Tools check current membership. Writes return an approval request and an Integrations link. Requests expire after 15 minutes, with at most 20 unexpired pending records per User. Legacy draft tools create inert proposals. Approving proposal creation does not apply its domain changes; the separate Apply step remains. Replaying an executed approval returns the stored result. ## Resources and widgets [#resources-and-widgets] The server adapts the same 15-tool catalog as the [assistant](/docs/features/assistant), subject to OAuth scope filtering. It exposes authorized Todo resources and planning prompts. Todo tool results can advertise both MCP Apps and OpenAI widget resources; hosts without iframe support still receive JSON. Public demo posts/profiles are separate static content, not private domain records. `template://organization/documents/{fileId}` returns the first bounded JSON page of converted text. Continue through `document_read` with `nextCursor`. The `plan-from-document` prompt reads relevant pages and calls `draft_task_plan`; creating that proposal needs approval and its separate Apply step creates Todos. Document resources use the same membership and read-scope checks as tools. Files uploaded by external clients still need [browser text preparation](/docs/features/documents) before document tools can read them. ## Extend [#extend] Add shared operations in the catalog, retain scope filtering and the in-process JWKS lookup, and use the server builder per connection. A Worker HTTP self-fetch for JWKS is not equivalent. Verify protocol errors, revoked tokens, insufficient scope and approval binding. Do not advertise an experimental MCP server-card endpoint as a stable discovery requirement. ## Key files [#key-files] - [`packages/api/src/features/mcp/mcp.server.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/mcp/mcp.server.ts) - [`packages/api/src/features/mcp/mcp.guard.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/mcp/mcp.guard.ts) - [`packages/api/src/features/mcp/mcp.context.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/mcp/mcp.context.ts) - [`packages/auth/src/mcp-oauth.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/auth/src/mcp-oauth.ts) - [`apps/web/src/routes/api/mcp.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/routes/api/mcp.ts) # Mobile app Expo Router uses the shared domain API with native navigation and scoped local state. ## Application structure [#application-structure] `apps/mobile` uses Expo SDK 57, React Native and file-based Expo Router routes. Auth routes sit outside the protected tabs. Home, Todos and Profile demonstrate the shared API; Profile contains Sessions, passkeys, Organizations, notifications and optional subscriptions. The native design layer centralizes colors, type, spacing and reusable controls. Hooks own mutations, auth transitions, dialogs and toasts. Keep route components focused on composition rather than duplicating transport policy. ## Backend and scope [#backend-and-scope] `EXPO_PUBLIC_SERVER_URL` selects the backend, with localhost:3005 as the local fallback. A physical device needs a reachable laptop LAN address. Better Auth's Expo client owns credentials; request scope captures User, Session and Organization. Organization transitions cancel stale waits and hide stale query results, including an A-to-B-to-A transition. Offline Todo reading is scoped to the observed identity and Organization. Cached data is not authorization, and a late response from an old identity must not clear or overwrite the new one. ## Native capabilities [#native-capabilities] RevenueCat requires its SDK in the native build and both billing gates. Push boot registers the platform adapter where available, with enrollment, foreground presentation and tap authorization kept separate. Widgets are opt-in through configuration and a feature flag. Biometric app lock protects local presentation, not server permission. ## Extend and verify [#extend-and-verify] Use the shared API contracts, then expose behavior through a mobile hook and the existing design components. Test on a development build when adding native modules. TypeScript and Hermes export do not prove device permission dialogs, purchases, background push or store signing. The Expo SDK compatibility matrix owns native versions. Do not bump native modules merely because npm has a newer release; rebuild the dev client when the native dependency set changes. See [libraries and upgrade policy](/docs/libraries). ## Key files [#key-files] - [`apps/mobile/src/app/_layout.tsx`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/mobile/src/app/_layout.tsx) - [`apps/mobile/src/lib/orpc.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/mobile/src/lib/orpc.ts) - [`apps/mobile/src/lib/organization-transition.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/mobile/src/lib/organization-transition.ts) - [`apps/mobile/src/lib/todo-cache.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/mobile/src/lib/todo-cache.ts) - [`apps/mobile/src/design/tokens.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/mobile/src/design/tokens.ts) - [`apps/mobile/README.md`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/mobile/README.md) # Open Graph images Takumi renders typed React templates to PNG on Node and workerd. ## Request to image [#request-to-image] The OG registry pairs each template with its input schema and render function. The route validates query parameters, selects a supported locale and renders the configured image dimensions. Home, default, post and profile templates cover public pages. Node development uses the native Takumi renderer. Workerd uses the WASM renderer and bundled font data. The existing renderer adapter contains historical runtime-specific imports; documentation adds no new dynamic imports. ## React Compiler boundary [#react-compiler-boundary] OG components are called by the image renderer outside React's normal dispatcher. They use the `"use no memo"` directive so React Compiler does not insert hook-dependent memoization. ```tsx function ImageHeading({ title }: { title: string }) { "use no memo"; return
{title}
; } ``` Use renderer-supported layout and inline styles. Do not assume browser CSS, client hooks or arbitrary DOM APIs work inside an OG template. ## Add a template [#add-a-template] Define a bounded Zod parameter schema, add a registry entry and render with the shared frame. Connect it through the SEO helper so canonical image URLs use the intended origin. Test every locale with native rendering and verify the actual built Worker path; a Node PNG is not proof that the WASM path works. ## Key files [#key-files] - [`apps/web/src/og/registry.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/og/registry.ts) - [`apps/web/src/og/render.tsx`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/og/render.tsx) - [`apps/web/src/og/render-wasm.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/og/render-wasm.ts) - [`apps/web/src/og/frame.tsx`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/og/frame.tsx) - [`apps/web/src/lib/seo/og-url.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/lib/seo/og-url.ts) # Payments Stripe, Polar and RevenueCat feed one server-verified entitlement model. ## Enable providers deliberately [#enable-providers-deliberately] All providers start off. Set `BILLING_PROVIDERS` to the required comma-separated names: `stripe`, `polar`, `revenuecat`. Each listed provider must have its complete valid configuration. `BILLING_ENVIRONMENT` distinguishes SANDBOX from PRODUCTION; Stripe key mode must agree. Stripe and Polar sell on the web. RevenueCat connects store purchases on mobile. If both web providers are enabled, `BILLING_CHECKOUT_PROVIDER` chooses new checkouts; existing customers retain their provider portal. Visibility separately uses `billing.web` and `billing.mobile`. ## Entitlements and webhooks [#entitlements-and-webhooks] Paid Access belongs to the User, not an Organization. The server's entitlement read model projects enabled-provider rows in the current environment. Device-reported purchases alone grant nothing. RevenueCat reconciliation replaces only its provider rows; Stripe and Polar apply signed, deduplicated events in transactions. | Provider | Webhook path | Server secrets | | --- | --- | --- | | Stripe | `/api/billing/stripe` | STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_IDS | | Polar | `/api/billing/polar` | POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET, POLAR_PRODUCT_IDS | | RevenueCat | `/api/billing/revenuecat` | REVENUECAT_SECRET_API_KEY, REVENUECAT_WEBHOOK_AUTHORIZATION, REVENUECAT_WEBHOOK_SIGNING_SECRET | Configure RevenueCat app and entitlement IDs too. Public platform SDK keys belong in the mobile environment; server keys do not. Raw webhook verification precedes payload parsing. A replay is a no-op, and a failed write rolls back its dedupe record so the provider can retry. ## Extend and test [#extend-and-test] Add plans to the leaf `billing-plans.ts`, map provider product IDs in env, and add name/description translations. Checkout return URLs come from the server, never a client-supplied redirect. Test purchases in sandbox and verify `billing.status`, cancellation and webhook replay before enabling production visibility. Stripe and Polar currently rely on webhooks plus the grace window; no scheduled pull reconciles their subscriptions. RevenueCat verification has a freshness bound. Native purchases require a development build with the SDK, not just a successful TypeScript check. See [ADR-0004](/docs/adr/0004-payments-providers). ## Key files [#key-files] - [`packages/api/src/features/billing/billing.settings.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/billing/billing.settings.ts) - [`packages/api/src/features/billing/billing.webhook.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/billing/billing.webhook.ts) - [`packages/api/src/features/billing/billing.status.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/billing/billing.status.ts) - [`packages/api/src/features/billing/billing.plans.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/billing/billing.plans.ts) - [`apps/mobile/src/lib/purchases-sdk.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/mobile/src/lib/purchases-sdk.ts) - [`docs/billing.md`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/billing.md) # Scheduled jobs One cron tick settles six concurrent jobs, then indexes Document Text within the same deadline and scoped pool. ## What runs [#what-runs] The Worker scheduled handler calls `runScheduledJobs` on the one-minute cron. The program runs audits, an hourly auth cleanup (rate-limit rows, sessions and verifications a day past expiry), imports, upload recovery, webhooks, billing and push concurrently. A typed failure or defect in one job does not interrupt its siblings. After these jobs settle, document maintenance and indexing run with their own 15-second budget inside the remaining global deadline. Indexing processes one document per tick; larger backlogs need multiple ticks. Failures become a single scheduled execution error identifying the failed jobs. Document conversion runs in a browser Web Worker. Cron validates and indexes submitted Markdown; it does not run AnyDoc. [ADR-0008](/docs/adr/0008-documents-and-large-uploads) records the memory evidence behind this ordering and the 192 KiB text cap. A global timeout before the earlier jobs settle skips document work. The tick acquires one job pool in an Effect scope. Its 25-second deadline interrupts work; fetch cancellation and drained database promises settle before the pool closes. An interrupted summary has empty counters, while durable rows and traces retain completed work. ## Run locally [#run-locally] ```sh pnpm jobs:run # Production-runtime recovery and network-fault verification: node scripts/test-local-postgres.mjs exec node scripts/test-local-jobs.mjs ``` The CLI uses the configured database. Use the disposable wrapper for tests. The workerd harness calls `/cdn-cgi/handler/scheduled` with `--test-scheduled` and uses a local R2 fixture and TCP fault proxy. It verifies replay and connection cleanup, not Cloudflare's production scheduling latency. ## Add a job [#add-a-job] Return an Effect from the new job, register it in the composed program and summary, and use the scoped database passed to it. Model durable admission, claim ownership, replay and terminal failure before adding retry. Keep external write attempts idempotent where possible; do not add in-round retries to sends. Test deadlines with `@effect/vitest` and TestClock using production values. Changes to queue persistence or deadlines must repeat the fault tests in [ADR-0006](/docs/adr/0006-effect-persisted-queue). ## Key files [#key-files] - [`apps/web/src/server.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/server.ts) - [`packages/api/src/platform/jobs/run.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/jobs/run.ts) - [`packages/api/src/platform/jobs/failure.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/jobs/failure.ts) - [`packages/db/src/job-db.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/db/src/job-db.ts) - [`scripts/test-local-jobs.mjs`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/scripts/test-local-jobs.mjs) # Todo imports Import an uploaded JSON file with durable claims, version checks and safe replay. ## Input and admission [#input-and-admission] Upload and confirm a JSON file in the Active Organization, then call `imports.start` with its key and a UUID `requestKey`. The operation records an Import Run and returns. HTTP never executes the import in the background. Reusing the Organization/request key returns the existing run; changing its actor or source conflicts. ```json [ { "text": "Review the API contract", "priority": "high" }, { "text": "Write the release note", "priority": "medium" } ] ``` The payload is an array of 1 to 200 Todos, at most 1 MiB. The reader bounds streamed bytes independently of Content-Length and compares the ETag from the same GET against the version frozen when the run started. ## Claim, commit and acknowledge [#claim-commit-and-acknowledge] Each cron tick publishes up to 25 unpublished runs and processes at most 25 claims with two consumers. Postgres owns delivery state through `FOR UPDATE SKIP LOCKED`, a random lease token and a 60-second lease. Stable Effect Queue, Stream and Schedule coordinate local execution. Before inserting, the transaction rechecks current membership, the confirmed upload, the token and lease expiry. It inserts all Todos and marks the run succeeded atomically. Acknowledgement happens later. If its response is lost, the next claim sees terminal success and acknowledges without creating duplicates. ## Failures and limits [#failures-and-limits] Changed or missing sources, revoked membership, malformed JSON and oversized payloads become persisted failures. Transient reads or database failures defer the claim. Three claims exhaust the run; retrying a terminal failure requires a new request key. An import reserves a 15-second work budget inside the 25-second tick. Its GET timeout is 10 seconds. Acknowledged queue rows expire after 30 days; Import Runs retain idempotency history until the Organization is deleted. Imports currently do not emit the interactive router's per-Todo audit events. Concurrent interactive writes can interleave positions with imports. ## Extend the example [#extend-the-example] Keep validation, source-version fencing, lease ownership and atomic commit together. Add a source field to the Zod contract and persistence transaction, then cover replay and a lost response in real workerd. [ADR-0006](/docs/adr/0006-effect-persisted-queue) explains why native PersistedQueue was rejected. ## Key files [#key-files] - [`packages/api/src/features/imports/imports.contract.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/imports/imports.contract.ts) - [`packages/api/src/features/imports/imports.service.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/imports/imports.service.ts) - [`packages/api/src/features/imports/imports.queue.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/imports/imports.queue.ts) - [`packages/api/src/features/imports/imports.jobs.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/imports/imports.jobs.ts) - [`packages/api/src/features/imports/imports.commit.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/imports/imports.commit.ts) - [`packages/api/src/features/imports/imports.policy.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/imports/imports.policy.ts) # Webhooks and notifications Durable outboxes own delivery and retries across interruptions. ## Outbound webhooks [#outbound-webhooks] Webhook creation and delivery are separate. Domain changes record durable payloads, and the delivery worker claims rows with a lease before making an attempt. It signs the payload, applies bounded transport and SSRF checks, then settles delivery and endpoint health together under the current lease. A webhook POST receives one attempt per round. Persistent policy decides when to retry. Respect Retry-After without making a request hold the whole cron tick indefinitely. Test a lost response and expired lease; a network timeout does not prove that the receiver rejected the event. ## Push reminders [#push-reminders] The notification outbox records reminder work. Expo push sending and receipt reads have different retry semantics: receipt GETs can use the idempotent read helper. Enrollment, foreground presentation and tap responses each validate current User/Organization scope. Mobile suppresses unauthorized or stale presentation and navigates only to a fixed authorized destination. Cancelling a local listener cannot recall a notification already displayed by the operating system. ## Extend [#extend] Add event contracts to the existing event definitions and enqueue work in the domain transaction. Keep secrets and private payloads out of logs. Test duplicate delivery, lease fencing, disabled configuration and identity transitions. Device and provider delivery need separate verification from the local transport suite. ## Key files [#key-files] - [`packages/api/src/features/webhooks/webhooks.attempt.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/webhooks/webhooks.attempt.ts) - [`packages/api/src/features/webhooks/webhooks.persistence.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/webhooks/webhooks.persistence.ts) - [`packages/api/src/features/webhooks/webhooks.lease.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/webhooks/webhooks.lease.ts) - [`packages/api/src/features/notifications/notifications.outbox.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/notifications/notifications.outbox.ts) - [`packages/api/src/features/notifications/notifications.jobs.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/notifications/notifications.jobs.ts) - [`apps/mobile/src/lib/push-presentation.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/mobile/src/lib/push-presentation.ts) # Developer documentation Build, extend and operate the Cloudflare web and Expo mobile template. The template combines a server-rendered web app, an Expo mobile app and shared domain packages. One Cloudflare Worker handles web rendering, authenticated APIs, files, agent tools and scheduled work. Postgres stores application state; R2 stores private objects. Configure identity, environment and a development database. Follow a request and learn the server boundary. Find ownership, key files and failure cases. See what landed and why it matters. ## Quickstart [#quickstart] ```sh git clone https://github.com/CarlosZiegler/template-cloudflare.git cd template-cloudflare pnpm install pnpm setup pnpm dev ``` Setup configures project identity and local environments, and guides database and provider configuration. The web defaults to `http://localhost:3005`; Expo starts separately through the workspace dev task. See [local setup](/docs/development/setup) before connecting a shared database. Apply reviewed migrations; never push the schema. ## Stack at a glance [#stack-at-a-glance] | Layer | Choice | | --- | --- | | Web | TanStack Start 1.0, React, Vite, Tailwind CSS and shadcn/Base UI | | Native | Expo, Expo Router and React Native | | API and contracts | oRPC, Zod and TanStack Query | | Identity | Better Auth with Organizations, passkeys and two-factor | | Data | Drizzle, Postgres/Neon and Cloudflare Hyperdrive | | Background work | Stable Effect APIs and durable Drizzle leases | | Agents | Shared tool catalog, TanStack AI and MCP OAuth | | Documentation | Headless Fumadocs MDX/core, static highlighting and server-rendered articles | The [library reference](/docs/libraries) records pinned versions and upgrade policy. ## What is included [#what-is-included] Authentication and account security, Organization-scoped Todos and files, saved views, JSON imports, admin management, audit and activity records, feature flags, three-language app chrome, transactional email, provider-gated payments, AI approvals, MCP tools, OG images and agent-readable pages. Native purchases, widgets and ads have explicit activation paths; installed code does not imply configured providers. ## How these docs are organized [#how-these-docs-are-organized] The pages are arranged by altitude, from the map down to templates. Read only as far down as you need. | Altitude | Question | Where | | --- | --- | --- | | Map | Where do I look? | This page | | Principles | Why is it shaped this way? | [Architecture](/docs/architecture) | | Conventions | What is the exact rule? | [Backend conventions](/docs/development/backend-conventions), [Conventions](/docs/development/conventions), [Features](/docs/features) | | Reference | What exists, and what checks it? | [Packages](/docs/packages), [Review checklist](/docs/development/review-checklist), [Enforcement](/docs/development/enforcement) | | Templates | Give me the files. | The `domain-feature` skill in `.agents/skills/` | | Process | How do these evolve? | [Decisions](/docs/adr), [Changelog](/docs/changelog), [Runbooks](/docs/development/runbooks) | ## Repository map [#repository-map] {`apps/web/ Web routes, hooks, content and Worker entry apps/mobile/ Expo screens, hooks and native registration packages/ Shared domain and platform packages docs/ ADRs, operations guides and measurements scripts/ Verification, migrations and deployment helpers .agents/skills/ Project workflows for contributors`} Docs articles are written in English. Navigation controls follow the app language (English, German or French). Architectural decisions are [rendered from their original files](/docs/adr), so there is one source of truth. # Libraries and version policy Major dependencies, their declared versions, owners and upgrade constraints. Versions below are read from `pnpm-workspace.yaml`’s catalog and workspace manifests at this documentation revision. A range is shown as declared; the lockfile owns its resolved version. Package locations identify direct consumers, not every transitive import. ## Upgrade policy [#upgrade-policy] The catalog uses exact pins. `minimumReleaseAge: 2880` enforces a two-day quarantine, including transitive resolution and frozen installs. Choose the newest version old enough to qualify. The workspace currently has no `minimumReleaseAgeExclude` entries. Only a security fix may bypass the quarantine: an exact-version entry with its advisory commented, removed once the version is two days old. The docs integration pins `fumadocs-mdx` 15.4.7 and `fumadocs-core` 16.17.1. At selection time (2026-10-15 06:12 UTC), core 16.17.2 was less than 48 hours old. No docs dependency receives a quarantine exclusion. No `fumadocs-ui` is installed. Native modules follow Expo’s compatibility check, not the newest npm release. Changing a native module invalidates the compiled development client and requires a rebuild. Web React can advance independently; native React stays compatible with React Native’s renderer. `pnpm update:native` runs Expo’s fix workflow; review its changes as an SDK migration. Keep Drizzle kit/ORM on the same build, Better Auth and its adapters compatible, the MCP SDK verified with the auth plugin, and one files-sdk copy across consumers. The files-sdk patch is version-specific. Effect persistence/pin changes require ADR-0006’s workerd fault gate. The JavaScript updater deliberately refuses coupled/native upgrades that need their own verification. ## Dependency reference [#dependency-reference] | Library | Declared version | Why it is here | Direct consumers | | --- | --- | --- | --- | | `@base-ui/react` | `1.9.0` | Accessible unstyled UI primitives. | `packages/ui` | | `@better-auth/core` | `1.7.7` | Better Auth plugin/adapter; upgraded with its core family. | `packages/auth` | | `@better-auth/drizzle-adapter` | `1.7.7` | Better Auth plugin/adapter; upgraded with its core family. | `packages/auth` | | `@better-auth/expo` | `1.7.7` | Better Auth plugin/adapter; upgraded with its core family. | `apps/mobile`, `packages/auth` | | `@better-auth/mcp` | `1.7.7` | Better Auth plugin/adapter; upgraded with its core family. | `apps/web`, `packages/auth` | | `@better-auth/oauth-provider` | `1.7.7` | Better Auth plugin/adapter; upgraded with its core family. | `apps/web`, `packages/auth` | | `@better-auth/passkey` | `1.7.7` | Better Auth plugin/adapter; upgraded with its core family. | `apps/web`, `apps/mobile`, `packages/auth` | | `@clack/prompts` | `1.8.1` | Interactive setup prompts. | `packages/project-setup-cli` | | `@cloudflare/vite-plugin` | `1.63.1` | Build TanStack Start for workerd. | `apps/web` | | `@ddedic/expo-fancy-ota-updates` | `^1.5.0` | OTA update presentation. | `apps/mobile` | | `@effect/sql-pg` | `4.0.2` | Workerd persisted-queue spike; not the production queue backend. | `packages/api` | | `@effect/vitest` | `4.0.2` | Test Effect timeouts using a virtual clock. | `packages/api`, `packages/email` | | `@expo/ui` | `~57.0.22` | Native SwiftUI/Compose interface surfaces. | `apps/mobile`, `packages/widgets` | | `@firecrawl/anydoc-wasm` | `0.2.4` | Browser document conversion to Markdown; patched for per-conversion instances. | `apps/web`, `packages/api` | | `@fontsource-variable/inter` | `5.3.0` | Locally hosted product typography. | `apps/web`, `packages/ui` | | `@headless-tree/core` | `1.7.0`, `^1.7.0` | Headless hierarchical tree behavior. | `apps/web`, `packages/ui` | | `@headless-tree/react` | `1.7.0` | React tree integration. | `apps/web` | | `@modelcontextprotocol/server` | `2.3.1` | MCP server protocol and tool transport. | `apps/web`, `packages/api` | | `@orpc/client` | `2.0.0-beta.43` | Typed RPC, OpenAPI or query adapter; keep the oRPC family aligned. | `apps/web`, `apps/mobile`, `packages/api`, `packages/errors` | | `@orpc/openapi` | `2.0.0-beta.43` | Typed RPC, OpenAPI or query adapter; keep the oRPC family aligned. | `apps/web`, `packages/api` | | `@orpc/server` | `2.0.0-beta.43` | Typed RPC, OpenAPI or query adapter; keep the oRPC family aligned. | `apps/web`, `packages/api`, `packages/errors` | | `@orpc/shared` | `2.0.0-beta.43` | Typed RPC, OpenAPI or query adapter; keep the oRPC family aligned. | `packages/api` | | `@orpc/tanstack-query` | `2.0.0-beta.43` | Typed RPC, OpenAPI or query adapter; keep the oRPC family aligned. | `apps/web`, `apps/mobile` | | `@orpc/zod` | `2.0.0-beta.43` | Typed RPC, OpenAPI or query adapter; keep the oRPC family aligned. | `apps/web`, `packages/api` | | `@shadcn/react` | `0.3.1` | Shared shadcn integration. | `packages/ui` | | `@shopify/flash-list` | `2.0.2` | Efficient native lists. | `apps/mobile` | | `@shopify/react-native-skia` | `2.6.2` | Native graphics. | `apps/mobile` | | `@t3-oss/env-core` | `0.13.11` | Validated environment contracts. | `packages/env` | | `@takumi-rs/core` | `2.15.1` | Native development rendering for OG images. | `apps/web` | | `@takumi-rs/wasm` | `2.15.1` | Worker-compatible OG renderer. | `apps/web` | | `@tanstack/ai` | `0.68.0` | Assistant streaming, client state, persistence or provider adapter. | `apps/web`, `packages/db`, `packages/api` | | `@tanstack/ai-anthropic` | `0.21.1` | Assistant streaming, client state, persistence or provider adapter. | `packages/api` | | `@tanstack/ai-client` | `0.39.2` | Assistant streaming, client state, persistence or provider adapter. | `apps/web` | | `@tanstack/ai-gemini` | `0.37.2` | Assistant streaming, client state, persistence or provider adapter. | `packages/api` | | `@tanstack/ai-openai` | `0.29.1` | Assistant streaming, client state, persistence or provider adapter. | `packages/api` | | `@tanstack/ai-persistence` | `0.8.3` | Assistant streaming, client state, persistence or provider adapter. | `packages/api` | | `@tanstack/ai-react` | `0.30.3` | Assistant streaming, client state, persistence or provider adapter. | `apps/web` | | `@tanstack/react-form` | `1.33.5` | Typed form state and validation. | `apps/web` | | `@tanstack/react-query` | `5.104.2` | Server state, caching and invalidation. | `apps/web`, `apps/mobile` | | `@tanstack/react-router` | `1.170.44` | Typed routing and navigation. | `apps/web` | | `@tanstack/react-router-ssr-query` | `1.167.3` | SSR/query hydration integration. | `apps/web` | | `@tanstack/react-start` | `1.169.1` | SSR and HTTP route entry points. | `apps/web`, `packages/intl` | | `@tanstack/react-table` | `9.3.1` | Data table state. | `apps/web` | | `@tanstack/react-virtual` | `3.14.17` | Virtualized lists for the ReUI cascader. | `packages/ui` | | `@types/node` | `26.6.5` | Compile-time declarations; no application runtime. | `.`, `apps/mobile`, `packages/env` | | `@types/react` | `19.2.4`, `~19.2.4` | Compile-time declarations; no application runtime. | `apps/web`, `apps/mobile`, `packages/ads`, `packages/ui`, `packages/purchases`, `packages/intl`, `packages/react-utils`, `packages/app-update`, `packages/widgets` | | `@types/react-dom` | `19.3.0` | Compile-time declarations; no application runtime. | `apps/web`, `packages/ui`, `packages/intl` | | `aws4fetch` | `1.0.20` | Worker-side S3 signing for direct and multipart R2 uploads. | `packages/storage` | | `babel-plugin-react-compiler` | `1.0.0` | Compile supported React components/hooks. | `apps/web` | | `better-auth` | `1.7.7` | Identity, sessions and plugin integration. | `apps/web`, `apps/mobile`, `packages/auth`, `packages/db` | | `class-variance-authority` | `0.7.1` | Typed component variants. | `packages/ui` | | `cmdk` | `1.1.1` | Command palette behavior. | `packages/ui` | | `cn` | `0.4.0` | Conditional class composition. | `packages/ui` | | `date-fns` | `4.4.0` | Date manipulation in shared UI. | `apps/web`, `packages/ui` | | `dotenv` | `18.0.7` | Load local server/script environment files. | `.`, `apps/web`, `packages/auth`, `packages/env`, `packages/db`, `packages/api` | | `drizzle-kit` | `1.0.0-rc.5-5935859` | Generate reviewed SQL migrations with the matching ORM build. | `packages/db` | | `drizzle-orm` | `1.0.0-rc.5-5935859` | Typed queries, schema and relations. | `apps/web`, `packages/flags`, `packages/auth`, `packages/db`, `packages/api` | | `effect` | `4.0.2` | Bounded jobs and outbound transports, using stable APIs. | `packages/api`, `packages/email` | | `embla-carousel-react` | `8.6.0` | Carousel primitives. | `packages/ui` | | `evlog` | `2.31.0` | Structured application logging. | `apps/web` | | `expo` | `~57.0.27` | Native SDK and build/runtime integration. | `apps/mobile` | | `expo-router` | `~57.0.25` | File-based native navigation. | `apps/mobile` | | `expo-widgets` | `~57.0.23` | iOS widget integration. | `apps/mobile`, `packages/widgets` | | `files-sdk` | `2.6.2` | Files gateway, storage operations and soft deletion. | `apps/web`, `packages/storage`, `packages/api` | | `fumadocs-core` | `16.17.1` | Page tree, metadata loading and server search without a UI theme. | `apps/web` | | `fumadocs-mdx` | `15.4.7` | Compile MDX, frontmatter, searchable text and processed Markdown at build time. | `apps/web` | | `i18next` | `26.4.4` | Language resources and translation. | `apps/web`, `apps/mobile`, `packages/intl` | | `input-otp` | `1.5.0` | One-time-code input. | `packages/ui` | | `lucide-react` | `1.56.0` | Consistent interface icons. | `apps/web`, `packages/ui` | | `neon` | `8.4.1` | Setup-time Neon provisioning. | `packages/project-setup-cli` | | `next-themes` | `0.4.6` | Existing web theme provider. | `apps/web`, `packages/ui` | | `nitro` | `3.0.260903-beta` | Node development/server build adapter. | `apps/web` | | `oxfmt` | `0.72.0` | Formatting. | `.` | | `oxlint` | `1.87.0` | Linting and module-boundary rules. | `.` | | `pg` | `8.23.1` | Postgres transport for Drizzle. | `apps/web`, `packages/auth`, `packages/db`, `packages/api` | | `qrcode.react` | `^4.2.0` | Web QR codes for account/security flows. | `apps/web` | | `react` | `19.2.3`, `19.3.0` | Component rendering; native follows its renderer version. | `apps/web`, `apps/mobile`, `packages/ads`, `packages/ui`, `packages/purchases`, `packages/intl`, `packages/react-utils`, `packages/app-update`, `packages/widgets` | | `react-day-picker` | `10.0.2` | Calendar selection. | `packages/ui` | | `react-dom` | `19.2.3`, `19.3.0` | Web rendering and server HTML output. | `apps/web`, `apps/mobile`, `packages/ui`, `packages/intl`, `packages/react-utils`, `packages/widgets` | | `react-i18next` | `17.0.16` | React translation bindings. | `apps/web`, `apps/mobile`, `packages/intl` | | `react-native` | `0.86.3` | Native renderer and platform primitives. | `apps/mobile`, `packages/ads`, `packages/purchases`, `packages/widgets` | | `react-native-android-widget` | `^0.21.0` | Android widget integration. | `apps/mobile`, `packages/widgets` | | `react-native-gesture-handler` | `~2.32.0` | Native gesture recognition. | `apps/mobile` | | `react-native-keyboard-controller` | `1.21.9` | Native keyboard coordination. | `apps/mobile` | | `react-native-passkey` | `^3.6.1` | Native passkey integration. | `apps/mobile` | | `react-native-purchases` | `10.12.2` | RevenueCat native purchase SDK. | `apps/mobile` | | `react-native-reanimated` | `4.5.1` | Native animation execution. | `apps/mobile` | | `react-native-safe-area-context` | `~5.7.0` | Safe-area insets. | `apps/mobile` | | `react-native-screens` | `4.26.2` | Native navigation screens. | `apps/mobile` | | `react-native-svg` | `15.15.4` | Native vector graphics. | `apps/mobile` | | `react-native-web` | `~0.21.0` | Web adapter for React Native. | `apps/mobile` | | `react-native-worklets` | `0.10.1` | Worklet execution required by native animation. | `apps/mobile` | | `react-phone-number-input` | `3.4.18` | Phone input. | `packages/ui` | | `react-resizable-panels` | `4.14.5` | Resizable panels. | `packages/ui` | | `recharts` | `3.10.1` | Application charts. | `apps/web`, `packages/ui` | | `shadcn` | `4.21.4` | Component installation and registry tooling. | `packages/ui` | | `shiki` | `4.5.0` | Syntax highlighting; docs run it at build time. | `packages/ui` | | `sonner` | `2.0.8` | Toast feedback. | `apps/web`, `packages/ui` | | `tailwindcss` | `4.3.3` | Semantic utility styling. | `apps/web`, `packages/ui` | | `takumi-js` | `2.15.1` | JSX-to-OG rendering integration. | `apps/web` | | `turbo` | `2.11.7` | Workspace task orchestration. | `.` | | `tw-animate-css` | `1.4.0` | UI animation utilities. | `packages/ui` | | `typescript` | `7.0.2`, `~6.0.3` | Type checking; native uses the JS compiler line for Expo config. | `.`, `apps/web`, `apps/mobile`, `packages/ads`, `packages/ui`, `packages/purchases`, `packages/flags`, `packages/intl`, `packages/auth`, `packages/storage`, `packages/env`, `packages/react-utils`, `packages/db`, `packages/api`, `packages/errors`, `packages/app-update`, `packages/email`, `packages/widgets` | | `vaul` | `1.1.2` | Drawer behavior. | `packages/ui` | | `vite` | `8.3.4` | Web build pipeline and development server. | `apps/web` | | `vitest` | `5.0.4` | Unit and integration test runner. | `apps/web`, `apps/mobile`, `packages/ads`, `packages/ui`, `packages/purchases`, `packages/flags`, `packages/intl`, `packages/auth`, `packages/storage`, `packages/env`, `packages/react-utils`, `packages/db`, `packages/api`, `packages/errors`, `packages/app-update`, `packages/email`, `packages/widgets` | | `wrangler` | `4.149.0` | Worker build/deploy/local runtime. | `apps/web` | | `zod` | `4.6.7` | Validate public contracts and third-party payloads. | `.`, `apps/web`, `apps/mobile`, `packages/flags`, `packages/auth`, `packages/env`, `packages/db`, `packages/api`, `packages/errors` | | `@aws-sdk/client-s3` | `3.1150.0` | S3-compatible Node storage transport, uploads or signing. | `packages/storage` | | `@aws-sdk/lib-storage` | `3.1150.0` | S3-compatible Node storage transport, uploads or signing. | `packages/storage` | | `@aws-sdk/s3-presigned-post` | `3.1150.0` | S3-compatible Node storage transport, uploads or signing. | `packages/storage` | | `@aws-sdk/s3-request-presigner` | `3.1150.0` | S3-compatible Node storage transport, uploads or signing. | `packages/storage` | | `@expo/dom-webview` | `~57.0.1` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `@expo/metro-runtime` | `~57.0.16` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `@expo/vector-icons` | `^15.1.1` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-application` | `~57.0.3` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-clipboard` | `~57.0.2` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-constants` | `~57.0.21` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-dev-client` | `~57.0.19` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-device` | `~57.0.2` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-file-system` | `~57.0.7` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-font` | `~57.0.4` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-glass-effect` | `~57.0.4` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-haptics` | `~57.0.3` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-image` | `~57.0.5` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-linking` | `~57.0.12` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-local-authentication` | `~57.0.3` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-localization` | `~57.0.2` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-network` | `~57.0.2` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-notifications` | `~57.0.22` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-secure-store` | `~57.0.4` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-splash-screen` | `~57.0.9` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-status-bar` | `~57.0.1` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-store-review` | `~57.0.3` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-symbols` | `~57.0.3` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-system-ui` | `~57.0.4` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-updates` | `~57.0.25` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | | `expo-web-browser` | `~57.0.3` | Expo SDK platform capability; use the SDK-compatible version. | `apps/mobile` | # ads Optional native advertising: responsibilities, supported exports and extension points. ## Purpose [#purpose] The package registers a native ads module and exposes banner, interstitial and rewarded interfaces. Without a registered module it behaves as unavailable. Configuration, initialization and tracking permission remain explicit. ## Public exports [#public-exports] | Subpath | Target | | --- | --- | | `.` | `./src/index.ts` | These paths come from `packages/ads/package.json`. A `.` entry is the package root. Conditional targets distinguish platform or type resolution; consumers should import the subpath, not its source file. ## How to extend [#how-to-extend] Install a compatible native SDK and register its loader in mobile startup before enabling ads. Rebuild the development client after native changes. Check availability and permission outcomes; do not make an ad prerequisite for unrelated navigation. Read the [optional native advertising guide](/docs/features/mobile) for the full flow. ## Key files [#key-files] - [`packages/ads/package.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/ads/package.json) # api Authorized operations and shared contracts: responsibilities, supported exports and extension points. ## Purpose [#purpose] The server defines oRPC procedures, domain routers, the assistant/MCP catalog and scheduled jobs. Public schema subpaths validate wire data; `client` exports only the router client type. ## Public exports [#public-exports] Canonical subpaths follow `.//` (see [ADR-0011](/docs/adr/0011-backend-feature-shape)). Contracts are leaf modules that web and mobile may import; the other entries are server-only and listed in the client boundary configuration. | Subpath | Target | | --- | --- | | `./client` | `./src/client.ts` | | `./rpc-scope` | `./src/rpc-scope.ts` | | `./request-id` | `./src/platform/rpc/request-id.ts` | | `./push-messages` | `./src/features/notifications/notifications.push-messages.ts` | | `./context` | `./src/platform/rpc/context.ts` | | `./app-router` | `./src/platform/rpc/app-router.ts` | | `./jobs/run` | `./src/platform/jobs/run.ts` | | `./admin/contract` | `./src/features/admin/admin.contract.ts` | | `./audit/contract` | `./src/features/audit/audit.contract.ts` | | `./flags/contract` | `./src/features/flags/flags.contract.ts` | | `./todo/contract` | `./src/features/todo/todo.contract.ts` | | `./proposals/contract` | `./src/features/proposals/proposals.contract.ts` | | `./integrations/contract` | `./src/features/integrations/integrations.contract.ts` | | `./billing/contract` | `./src/features/billing/billing.contract.ts` | | `./notifications/contract` | `./src/features/notifications/notifications.contract.ts` | | `./imports/contract` | `./src/features/imports/imports.contract.ts` | | `./assistant/contract` | `./src/features/assistant/assistant.contract.ts` | | `./documents/contract` | `./src/features/documents/documents.contract.ts` | | `./agent-tools/contract` | `./src/features/agent-tools/agent-tools.contract.ts` | | `./billing/plans` | `./src/features/billing/billing.plans.ts` | | `./billing/http` | `./src/features/billing/billing.http.ts` | | `./billing/web-http` | `./src/features/billing/billing.web-http.ts` | | `./mcp/server` | `./src/features/mcp/mcp.server.ts` | | `./mcp/context` | `./src/features/mcp/mcp.context.ts` | | `./mcp/protocol` | `./src/features/mcp/mcp.protocol.ts` | | `./mcp/guard` | `./src/features/mcp/mcp.guard.ts` | | `./webhooks/events` | `./src/features/webhooks/webhooks.events.ts` | | `./uploads/service` | `./src/features/uploads/uploads.service.ts` | | `./assistant/http` | `./src/features/assistant/assistant.http.ts` | | `./documents/lifecycle-plugin` | `./src/features/documents/documents.lifecycle-plugin.ts` | ### Mobile aliases [#mobile-aliases] The native app imports four contracts through their earlier subpaths. They stay in `exports` and resolve to the same files as the canonical entries, so the app builds without a change. The web app uses the canonical subpaths only. | Alias | Target | | --- | --- | | `./routers/todo.schema` | `./src/features/todo/todo.contract.ts` | | `./routers/integrations.schema` | `./src/features/integrations/integrations.contract.ts` | | `./routers/billing.schema` | `./src/features/billing/billing.contract.ts` | | `./routers/notifications.schema` | `./src/features/notifications/notifications.contract.ts` | ### Renamed subpaths [#renamed-subpaths] These subpaths were renamed when the backend moved to one folder per feature. Import the new subpath. | Earlier subpath | Use instead | | --- | --- | | `./routers/index` | `./app-router` | | `./routers/admin.schema` | `./admin/contract` | | `./routers/audit.schema` | `./audit/contract` | | `./routers/flags.schema` | `./flags/contract` | | `./routers/proposals.schema` | `./proposals/contract` | | `./routers/imports.schema` | `./imports/contract` | | `./routers/assistant.schema` | `./assistant/contract` | | `./routers/documents.schema` | `./documents/contract` | | `./billing-plans` | `./billing/plans` | | `./uploads/workflow` | `./uploads/service` | | `./ai/http` | `./assistant/http` | | `./ai/catalog.schema` | `./agent-tools/contract` | These paths come from `packages/api/package.json`. Conditional targets distinguish platform or type resolution; consumers should import the subpath, not its source file. ## How to extend [#how-to-extend] Add the operation to the feature's procedures and service, and register new procedures in `platform/rpc/app-router.ts`. Keep authorization in the procedure and resource scope in the query. Add a `.//contract` export when another package needs the contract. The `domain-feature` skill walks through a new feature. For AI/MCP tools, extend the single catalog after reading ADR-0007. Read the [authorized operations and shared contracts guide](/docs/features/api) for the full flow. ## Key files [#key-files] - [`packages/api/package.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/package.json) # app-update Native update policy: responsibilities, supported exports and extension points. ## Purpose [#purpose] The pure core compares versions and resolves disabled, up-to-date, recommended and required update states. A hook factory adapts platform-specific fetch/open behavior. Loading and network errors do not force an update wall. ## Public exports [#public-exports] | Subpath | Target | | --- | --- | | `.` | `./src/index.ts` | | `./core` | `./src/core.ts` | | `./create-hook` | `./src/create-hook.ts` | These paths come from `packages/app-update/package.json`. A `.` entry is the package root. Conditional targets distinguish platform or type resolution; consumers should import the subpath, not its source file. ## How to extend [#how-to-extend] Keep policy testable in core and platform effects in the adapter. Distinguish an optional recommendation from a required supported-version boundary. Verify malformed and unavailable remote configuration. Read the [native update policy guide](/docs/features/mobile) for the full flow. ## Key files [#key-files] - [`packages/app-update/package.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/app-update/package.json) # auth Identity and request scope: responsibilities, supported exports and extension points. ## Purpose [#purpose] The Better Auth factory configures sessions, Organizations, admin, passkeys, two-factor, Expo and MCP OAuth. Request scope shares the authorized database/auth instance through one HTTP invocation. Outcome modules normalize sign-in and challenge results. `client-ip` is the one rule for the client address used by rate limits and audit: `cf-connecting-ip` on Workers, otherwise `TRUSTED_PROXY_HEADER`/`TRUSTED_PROXIES` when a Node deploy configures them, else the socket address. An invalid header name or proxy count is logged and ignored: the socket address is used, never a guessed hop ([runtime switches](/docs/development/environments#runtime-switches)). ## Public exports [#public-exports] | Subpath | Target | | ---------------------- | ----------------------------- | | `.` | `./src/index.ts` | | `./admin-audit` | `./src/admin-audit.ts` | | `./client-ip` | `./src/client-ip.ts` | | `./errors` | `./src/errors.ts` | | `./request-scope` | `./src/request-scope.ts` | | `./server-session` | `./src/server-session.ts` | | `./session-outcome` | `./src/session-outcome.ts` | | `./two-factor-outcome` | `./src/two-factor-outcome.ts` | These paths come from `packages/auth/package.json`. A `.` entry is the package root. Conditional targets distinguish platform or type resolution; consumers should import the subpath, not its source file. ## How to extend [#how-to-extend] Change the factory for server behavior and the outcome modules for shared interpretations. Test revoked sessions, bans, impersonation and Organization membership. Do not export client constants through a server entry. Read the [identity and request scope guide](/docs/features/auth) for the full flow. ## Key files [#key-files] - [`packages/auth/package.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/auth/package.json) # config Tooling configuration: responsibilities, supported exports and extension points. ## Purpose [#purpose] This package holds shared TypeScript and Oxlint configuration, including the client/server import boundary. It has no runtime API and no package exports map. Consumers reference configuration files. ## Public exports [#public-exports] This package has no public `exports` map. Use its documented configuration files or root workspace scripts. These paths come from `packages/config/package.json`. A `.` entry is the package root. Conditional targets distinguish platform or type resolution; consumers should import the subpath, not its source file. ## How to extend [#how-to-extend] Change a shared rule only after checking web, native and UI consumers. Keep nested boundary configs active. A lint exception needs a concrete framework or domain reason. Read the [tooling configuration guide](/docs/development/conventions) for the full flow. ## Key files [#key-files] - [`packages/config/package.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/config/package.json) # db Postgres schema and durable state: responsibilities, supported exports and extension points. ## Purpose [#purpose] Drizzle tables and relations describe auth and application state. The package owns connection lifetimes, migrations, audit/outbox writes, Organization provisioning and scheduled-job leases. Enum subpaths are client-safe leaves. ## Public exports [#public-exports] | Subpath | Target | | ------------------------------ | ------------------------------------- | | `.` | `./src/index.ts` | | `./audit` | `./src/audit.ts` | | `./relations` | `./src/relations.ts` | | `./schema` | `./src/schema/index.ts` | | `./schema/auth` | `./src/schema/auth.ts` | | `./schema/audit` | `./src/schema/audit.ts` | | `./schema/todo` | `./src/schema/todo.ts` | | `./schema/flags` | `./src/schema/flags.ts` | | `./schema/todo.enums` | `./src/schema/todo.enums.ts` | | `./schema/flags.enums` | `./src/schema/flags.enums.ts` | | `./schema/proposals` | `./src/schema/proposals.ts` | | `./schema/webhooks` | `./src/schema/webhooks.ts` | | `./schema/webhooks.enums` | `./src/schema/webhooks.enums.ts` | | `./schema/proposals.enums` | `./src/schema/proposals.enums.ts` | | `./provision-organization` | `./src/provision-organization.ts` | | `./schema/jobs` | `./src/schema/jobs.ts` | | `./job-db` | `./src/job-db.ts` | | `./schema/uploads` | `./src/schema/uploads.ts` | | `./schema/uploads.enums` | `./src/schema/uploads.enums.ts` | | `./schema/billing` | `./src/schema/billing.ts` | | `./schema/notifications` | `./src/schema/notifications.ts` | | `./schema/notifications.enums` | `./src/schema/notifications.enums.ts` | | `./schema/imports` | `./src/schema/imports.ts` | | `./schema/imports.enums` | `./src/schema/imports.enums.ts` | | `./schema/assistant` | `./src/schema/assistant.ts` | | `./schema/documents` | `./src/schema/documents.ts` | | `./schema/documents.enums` | `./src/schema/documents.enums.ts` | | `./tenancy` | `./src/tenancy.ts` | These paths come from `packages/db/package.json`. A `.` entry is the package root. Conditional targets distinguish platform or type resolution; consumers should import the subpath, not its source file. ## How to extend [#how-to-extend] Generate and review a journaled migration. Register needed relations in the schema aggregate. Keep enum values in import-free leaves and scope domain rows by Organization. Use transactions for domain changes and their audit/outbox records. Read the [postgres schema and durable state guide](/docs/features/database) for the full flow. ## Key files [#key-files] - [`packages/db/package.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/db/package.json) # email Transactional email delivery: responsibilities, supported exports and extension points. ## Purpose [#purpose] Server transports select the Cloudflare binding, Cloudflare REST, Resend or the console fallback from available configuration. Template helpers produce escaped HTML for verification, reset, invitations and other identity messages. ## Public exports [#public-exports] | Subpath | Target | | --- | --- | | `.` | `./src/index.ts` | | `./templates` | `./src/templates.ts` | | `./transports` | `./src/transports.ts` | These paths come from `packages/email/package.json`. A `.` entry is the package root. Conditional targets distinguish platform or type resolution; consumers should import the subpath, not its source file. ## How to extend [#how-to-extend] Add a template and call the existing send boundary. Delivery has a bounded timeout and does not automatically retry a send after an uncertain response. Configure a real provider before relying on user delivery. Read the [transactional email delivery guide](/docs/features/email) for the full flow. ## Key files [#key-files] - [`packages/email/package.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/email/package.json) # env Validated environment contracts: responsibilities, supported exports and extension points. ## Purpose [#purpose] Separate server, web, native, email and MCP resource entries validate their own configuration. Server secrets are parsed with Zod through the env helpers. Public web and native variables are intentionally limited. `runtime-flags` reads the deployment switches (`DB_TENANT_PIPELINE`, `TENANCY_PROBE`, `TRUSTED_PROXY_HEADER`, `TRUSTED_PROXIES`) on every call, without loading the whole server contract. ## Public exports [#public-exports] | Subpath | Target | | --- | --- | | `./server` | `./src/server.ts` | | `./mcp-resource` | `./src/mcp-resource.ts` | | `./web` | `./src/web.ts` | | `./native` | `./src/native.ts` | | `./email` | `./src/email.ts` | | `./runtime-flags` | `./src/runtime-flags.ts` | These paths come from `packages/env/package.json`. A `.` entry is the package root. Conditional targets distinguish platform or type resolution; consumers should import the subpath, not its source file. ## How to extend [#how-to-extend] Add a variable to its correct contract and setup/deploy path. Never add a secret to a VITE_ or EXPO_PUBLIC_ contract. Test missing, blank and malformed values; preserve existing credentials when rerunning setup. Read the [validated environment contracts guide](/docs/development/environments) for the full flow. ## Key files [#key-files] - [`packages/env/src/runtime-flags.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/env/src/runtime-flags.ts) - [`packages/env/package.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/env/package.json) # errors Stable error vocabulary: responsibilities, supported exports and extension points. ## Purpose [#purpose] Client-safe codes and resolvers turn transport errors into localized messages. Server handling logs unexpected failures and maps known errors to oRPC responses. Auth-client helpers interpret Better Auth errors. ## Public exports [#public-exports] | Subpath | Target | | --- | --- | | `.` | `./src/index.ts` | | `./auth-client-error` | `./src/auth-client-error.ts` | | `./locales` | `./src/locales/index.ts` | | `./server` | `./src/server.ts` | These paths come from `packages/errors/package.json`. A `.` entry is the package root. Conditional targets distinguish platform or type resolution; consumers should import the subpath, not its source file. ## How to extend [#how-to-extend] Introduce a stable code and catalog messages for an actionable new failure. Preserve its typed path across the boundary; do not display raw database/provider exception text. Read the [stable error vocabulary guide](/docs/architecture/observability) for the full flow. ## Key files [#key-files] - [`packages/errors/package.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/errors/package.json) # flags Feature registry and overrides: responsibilities, supported exports and extension points. ## Purpose [#purpose] The registry defines keys, descriptions and defaults. The server repository combines defaults with persisted overrides. Schemas and the registry are the safe contract; the repository can reach Postgres. ## Public exports [#public-exports] | Subpath | Target | | --- | --- | | `.` | `./src/index.ts` | | `./registry` | `./src/registry.ts` | | `./evaluate` | `./src/evaluate.ts` | | `./parse` | `./src/parse.ts` | | `./repo` | `./src/repo.ts` | These paths come from `packages/flags/package.json`. A `.` entry is the package root. Conditional targets distinguish platform or type resolution; consumers should import the subpath, not its source file. ## How to extend [#how-to-extend] Define a key and default centrally, expose it through the existing evaluation route and add a UI gate. A visibility flag does not replace authorization or required provider configuration. Read the [feature registry and overrides guide](/docs/features/feature-flags) for the full flow. ## Key files [#key-files] - [`packages/flags/package.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/flags/package.json) # Packages The workspace contains 17 packages with explicit ownership and supported import paths. `apps/web` owns the web experience and production Worker. `apps/mobile` owns Expo navigation and native integration. Shared contracts and implementations live in the packages below. A package boundary does not automatically make a module safe for a browser: follow the [client/server boundary](/docs/architecture/client-server-boundary). | Package | Responsibility | | --- | --- | | [api](/docs/packages/api) | Authorized operations and shared contracts | | [auth](/docs/packages/auth) | Identity and request scope | | [db](/docs/packages/db) | Postgres schema and durable state | | [storage](/docs/packages/storage) | Private file gateway | | [email](/docs/packages/email) | Transactional email delivery | | [env](/docs/packages/env) | Validated environment contracts | | [flags](/docs/packages/flags) | Feature registry and overrides | | [intl](/docs/packages/intl) | Request-scoped language | | [errors](/docs/packages/errors) | Stable error vocabulary | | [ui](/docs/packages/ui) | Shared web primitives | | [config](/docs/packages/config) | Tooling configuration | | [project-setup-cli](/docs/packages/project-setup-cli) | Project identity and provisioning workflow | | [ads](/docs/packages/ads) | Optional native advertising | | [app-update](/docs/packages/app-update) | Native update policy | | [purchases](/docs/packages/purchases) | Native purchase coordination | | [react-utils](/docs/packages/react-utils) | Small shared React helpers | | [widgets](/docs/packages/widgets) | Optional native Todo widgets | # intl Request-scoped language: responsibilities, supported exports and extension points. ## Purpose [#purpose] Shared i18next configuration recognizes English, German and French. The server creates a request-scoped instance; web and native supply their own resources. The web selects language from the URL, then cookie, then default. ## Public exports [#public-exports] | Subpath | Target | | --- | --- | | `.` | `./src/index.ts` | | `./server` | `./src/server.ts` | These paths come from `packages/intl/package.json`. A `.` entry is the package root. Conditional targets distinguish platform or type resolution; consumers should import the subpath, not its source file. ## How to extend [#how-to-extend] Add a message to all three catalogs and use the typed translation API. Keep shared locale constants separate from the server entry. Use Intl for user-visible dates and numbers. Read the [request-scoped language guide](/docs/features/i18n) for the full flow. ## Key files [#key-files] - [`packages/intl/package.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/intl/package.json) # project-setup-cli Project identity and provisioning workflow: responsibilities, supported exports and extension points. ## Purpose [#purpose] The root pnpm setup script invokes this Node CLI. It gathers project identity, writes local configuration, guides database and Cloudflare setup and checks credentials. pnpm doctor runs its diagnostic path. ## Public exports [#public-exports] This package has no public `exports` map. Use its documented configuration files or root workspace scripts. These paths come from `packages/project-setup-cli/package.json`. A `.` entry is the package root. Conditional targets distinguish platform or type resolution; consumers should import the subpath, not its source file. ## How to extend [#how-to-extend] Keep steps repeatable and preserve existing identity and credentials during repair. Add a validation/diagnosis path beside a new setup field. Never log secret values or silently replace configured remote resources. Read the [project identity and provisioning workflow guide](/docs/development/setup) for the full flow. ## Key files [#key-files] - [`packages/project-setup-cli/package.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/project-setup-cli/package.json) # purchases Native purchase coordination: responsibilities, supported exports and extension points. ## Purpose [#purpose] A vendor-neutral interface wraps the optional RevenueCat SDK. Configuration, connection and identity changes serialize SDK operations. A generation guard prevents stale identity results from updating the current customer state. React hooks expose offerings, purchase and restore state. ## Public exports [#public-exports] | Subpath | Target | | --- | --- | | `.` | `./src/index.ts` | | `./react` | `./src/react.ts` | These paths come from `packages/purchases/package.json`. A `.` entry is the package root. Conditional targets distinguish platform or type resolution; consumers should import the subpath, not its source file. ## How to extend [#how-to-extend] Register the supported SDK at native startup, configure its public key and keep identity changes inside the package. Client purchase state is presentation; server-verified Paid Access remains authoritative. Read the [native purchase coordination guide](/docs/features/payments) for the full flow. ## Key files [#key-files] - [`packages/purchases/package.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/purchases/package.json) # react-utils Small shared React helpers: responsibilities, supported exports and extension points. ## Purpose [#purpose] The use-latest subpath stores the latest callback/value in a ref for consumers that need stable subscriptions without stale closures. It has no server capability. ## Public exports [#public-exports] | Subpath | Target | | --- | --- | | `./use-latest` | `./src/use-latest.ts` | These paths come from `packages/react-utils/package.json`. A `.` entry is the package root. Conditional targets distinguish platform or type resolution; consumers should import the subpath, not its source file. ## How to extend [#how-to-extend] Use the existing helper for that specific ownership need. Prefer ordinary React state and dependencies when they already express the behavior. Do not turn this package into a miscellaneous barrel. Read the [small shared react helpers guide](/docs/development/conventions) for the full flow. ## Key files [#key-files] - [`packages/react-utils/package.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/react-utils/package.json) # storage Private file gateway: responsibilities, supported exports and extension points. ## Purpose [#purpose] The files-sdk adapter exposes authenticated uploads, reads and deletion over Cloudflare R2. Its server plugin connects object operations to application metadata and upload intents. Node development uses the configured S3-compatible endpoint. ## Public exports [#public-exports] | Subpath | Target | | --- | --- | | `.` | `./src/index.ts` | | `./config` | `./src/config.ts` | | `./upload-plugin` | `./src/upload-plugin.ts` | | `./direct` | `./src/direct.ts` | | `./direct-s3` | `./src/direct-s3.ts` | These paths come from `packages/storage/package.json`. A `.` entry is the package root. Conditional targets distinguish platform or type resolution; consumers should import the subpath, not its source file. ## How to extend [#how-to-extend] Extend the existing gateway/plugin so upload intent, ownership checks and metadata remain coordinated. Keep R2 credentials and storage adapters out of client imports. Exercise cleanup after an interrupted upload. Read the [private file gateway guide](/docs/features/files) for the full flow. ## Key files [#key-files] - [`packages/storage/package.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/storage/package.json) # ui Shared web primitives: responsibilities, supported exports and extension points. ## Purpose [#purpose] The web component library wraps Base UI primitives with the repository’s neutral shadcn tokens. It includes buttons, dialogs, sidebar, tables, command, code blocks and the shared runtime highlighter. Docs use build-time highlighting instead. Shadcn `trace` and `code-block` components support execution details and code displays. The public ReUI registry adds free `time-picker`, `color-picker`, `signature-pad`, `file-type`, `emoji-picker` and `cascader` components. These are installed source files in this package. Pro blocks require a license and are not included. See the [ReUI registry](https://reui.io/docs/registry) for access rules. EmojiPicker fetches `emojibase-data@17.0.0` from jsDelivr by default; pass local `data` when external loading is unwanted. Fumadocs is confined to `/docs`, with headless source/search and build-time MDX. It does not replace application components or install `fumadocs-ui`. ## Public exports [#public-exports] | Subpath | Target | | --- | --- | | `./globals.css` | `./src/styles/globals.css` | | `./lib/*` | `./src/lib/*.ts` | | `./lib/highlighter` | `./src/lib/highlighter.tsx` | | `./components/*` | `./src/components/*.tsx` | | `./components/reui/*` | `./src/components/reui/*.tsx` | | `./components/reui/time-picker` | `./src/components/reui/time-picker.tsx` | | `./components/reui/color-picker` | `./src/components/reui/color-picker.tsx` | | `./components/reui/signature-pad` | `./src/components/reui/signature-pad.tsx` | | `./components/reui/file-type` | `./src/components/reui/file-type.tsx` | | `./components/reui/emoji-picker` | `./src/components/reui/emoji-picker.tsx` | | `./components/reui/cascader/cascader` | `./src/components/reui/cascader/cascader.tsx` | | `./components/reui/cascader/cascader-async` | `./src/components/reui/cascader/cascader-async.tsx` | | `./components/reui/cascader/cascader-columns` | `./src/components/reui/cascader/cascader-columns.tsx` | | `./components/reui/cascader/cascader-context` | `./src/components/reui/cascader/cascader-context.tsx` | | `./components/reui/cascader/cascader-footer` | `./src/components/reui/cascader/cascader-footer.tsx` | | `./components/reui/cascader/cascader-i18n` | `./src/components/reui/cascader/cascader-i18n.tsx` | | `./components/reui/cascader/cascader-item` | `./src/components/reui/cascader/cascader-item.tsx` | | `./components/reui/cascader/cascader-lib` | `./src/components/reui/cascader/cascader-lib.tsx` | | `./components/reui/cascader/cascader-nav` | `./src/components/reui/cascader/cascader-nav.tsx` | | `./components/reui/cascader/cascader-types` | `./src/components/reui/cascader/cascader-types.tsx` | | `./components/reui/cascader/cascader-virtual` | `./src/components/reui/cascader/cascader-virtual.tsx` | | `./hooks/*` | `./src/hooks/*.ts` | | `./postcss.config` | `./postcss.config.mjs` | These paths come from `packages/ui/package.json`. A `.` entry is the package root. Conditional targets distinguish platform or type resolution; consumers should import the subpath, not its source file. ## How to extend [#how-to-extend] Reuse an existing primitive before adding one. Put feature data, effects and navigation in application hooks. Preserve accessible labels, keyboard behavior and visible focus. Existing component/lib export patterns are local to this UI package; they do not permit server barrels. Read the [shared web primitives guide](/docs/development/conventions) for the full flow. ## Key files [#key-files] - [`packages/ui/package.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/ui/package.json) # widgets Optional native Todo widgets: responsibilities, supported exports and extension points. ## Purpose [#purpose] The shared model derives Todo counts and a bounded list of open titles. Platform adapters synchronize iOS snapshots and Android widgets. Native widget configuration is opt-in and the widgets.todo-summary flag defaults off. ## Public exports [#public-exports] | Subpath | Target | | --- | --- | | `.` | `./src/index.ts` | | `./model` | `./src/model.ts` | | `./sync` | `./src/sync.ts` | | `./ios` | `./src/todo-widget-ios.tsx` | | `./android` | `./src/todo-widget-android.tsx` | These paths come from `packages/widgets/package.json`. A `.` entry is the package root. Conditional targets distinguish platform or type resolution; consumers should import the subpath, not its source file. ## How to extend [#how-to-extend] Enable the platform integration deliberately, rebuild the native client and synchronize only the current authorized scope. Clear private widget state on identity changes. Keep platform-only imports behind their explicit entry. Read the [optional native todo widgets guide](/docs/features/mobile) for the full flow. ## Key files [#key-files] - [`packages/widgets/package.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/widgets/package.json) # ADR-0001 — Stored file keys keep the user's filename The repository's architectural decision, rationale and consequences. # ADR-0001 — Stored file keys keep the user's filename **Status:** accepted · **Date:** 2026-07-13 · Clarified 2026-10-01 (organization isolation, ADR-0003) ## Context [#context] `packages/storage` shipped a `sanitizeFileName` helper — lowercase, strip unsafe characters, collapse whitespace, preserve one extension — that **nothing in production ever called**. It was exercised only by its own tests. An architecture review flagged this as a gap: user-supplied filenames become object keys unsanitized. The obvious fix is to wire the sanitizer in at the upload boundary. We are deliberately not doing that. ## Decision [#decision] A stored file's key keeps the name the user gave it. The sanitizer is deleted rather than wired in. ## Why [#why] **The raw name is a feature, not an oversight.** The upload composer lets a user _edit_ the name a file is stored under before uploading it, and that name becomes the key. Sanitizing would silently overwrite an explicit choice the user just made — a file the user named `Relatório Final.PDF` would come back as `relatorio-final.pdf`. The file gallery displays `file.name || file.key`, so the change would be visible. **The filename does not choose the Organization.** The gateway applies `orgs//` server-side after checking the current Session and membership. This organization prefix replaced the original user prefix under [ADR-0003](0003-organizations-are-the-tenant.md). Client-supplied names cannot choose or escape the organization prefix. Object-storage keys are flat strings, so a `..` segment is a literal character sequence rather than a parent directory. **Uploads are already gated where it matters.** `validateUpload` enforces the size limit and the MIME allowlist. That is the boundary that protects the system; the filename is presentation. ## Consequences [#consequences] - Arbitrary user input (spaces, unicode, long names) appears in stored keys and in URLs derived from them. Anything consuming a key must treat it as opaque and encode it for transport — not assume it is URL-safe. - If a future requirement _does_ demand sanitized keys (a storage backend with key restrictions, say), the display name must be preserved separately so the user still sees the name they chose. Reopen this ADR rather than sanitizing in place. - `sanitizeFileName` and `packages/storage/src/keys.ts` are gone. Do not reintroduce them without revisiting this decision. ## Related [#related] - Supersedes the "wire the sanitizer in" candidate raised in issue #36. - The current organization isolation rule lives in the files gateway's `keyPrefix` and [ADR-0003](0003-organizations-are-the-tenant.md). The historical discussion in #32 deleted a second, contradictory key-building rule. # ADR-0002 — Server-capable modules have no barrel The repository's architectural decision, rationale and consequences. # ADR-0002 — Server-capable modules have no barrel **Status:** accepted · **Date:** 2026-07-30 **Enforcement:** `pnpm exec oxlint` (nested configs extending `packages/config/oxlint-client-boundary.json`), enumerated package `exports`, and `apps/web/scripts/assert-client-bundle-clean.mjs` in `pnpm --filter web build`. ## Context [#context] A barrel is a module whose job is to re-export its neighbours (`index.ts`), or — more insidiously — a module that carries real logic _and_ re-exports a few conveniences for someone else. Barrels are pleasant to import from and they are the reason server code ends up in a browser. The failure is not hypothetical. A production build of `apps/web` on 2026-07-30 shipped the database's DDL to every anonymous visitor: | Client asset | Leaked content | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `assets/auth-*.js` (37 KB) | `pgTable` definitions for `user`, `session`, `account`, `verification`, `two_factor`, `rate_limit` — column names including `impersonated_by` — plus drizzle's `Table`/`Column` machinery (`Symbol.for("drizzle:entityKind")`). Imported by the landing page, the dashboard and the flags chunks. | | `assets/index-*.js` | the `todo` table literal and its `todo_userId_idx` index | | `assets/flags-*.js` (19 KB) | `feature_flag` and `app_update_config` table literals | Nobody wrote an import of the database from a component. The chain was built out of good intentions: ``` routes/_auth/todos.tsx import { PRIORITIES } from "@template-cloudflare/api/routers/todo.schema" → api/routers/todo.schema import { PRIORITIES } from "@template-cloudflare/db/schema/todo" → db/schema/todo.ts export const PRIORITIES = [...] ← the constant the UI wanted export const todo = pgTable(...) ← the side effect it got ``` `PRIORITIES` was declared in the drizzle table module so the enum would have a single source of truth — a good instinct. But `pgTable(...)` runs at module scope, no package declares `sideEffects`, so Rollup must keep it. Importing one three-element array pulled in the schema of the authentication system. The same shape leaked `FLAG_KINDS`, and `ADMIN_ROLES` sat inside `lib/admin-api.ts` next to `@orpc/server` and `better-auth/api` imports, saved from shipping only by tree-shaking that nothing guarantees across a dependency bump. The one thing that _was_ protecting us is worth naming, because it is thinner than it looks. `apps/web/src/utils/orpc.ts` value-imports the entire `appRouter` — every procedure, the audit seam, all schemas — and is in the client graph of every page. It never reaches the browser because the TanStack Start compiler strips the `createIsomorphicFn().server()` branch and DCE removes the now-unused imports. Correctness there rests on a framework transform, not on the type system. Mobile and native already model the safer shape (`import type { AppRouterClient }`), where one erased keyword is all that stands between the app binary and the backend. ## Decision [#decision] **A module that can reach the server — a database connection, `env/server`, Better Auth, the storage or email transport, an oRPC router — exports no conveniences for anyone else.** No barrel, no re-export of a neighbour's constant, no "while you're here". Its public surface is the server capability and nothing more. **Anything a client needs is a leaf: a module with zero imports, or imports only of other leaves.** Domain enums (`PRIORITIES`, `FLAG_KINDS`, `ADMIN_ROLES`) live in dependency-free modules. The drizzle schema and the API contract both import _from_ the leaf; neither is the leaf's owner. **A client's address for a shared type is type-only by construction.** `@template-cloudflare/api/client` exports types and not one value, so `import type` versus `import` stops being a distinction that matters. **The drizzle schema barrel (`packages/db/src/schema/index.ts`) stays.** It is the sanctioned exception: drizzle's relations builder and drizzle-kit genuinely want every table in one object, it is reached only from `relations.ts`, and it is unreachable from any client. A barrel is fine when the aggregate _is_ the interface. That is rare. Enforcement is mechanical, not cultural — nested `oxlint` `no-restricted-imports` configs with `allowTypeImports: true`, package `exports` maps that enumerate real subpaths instead of `"./*"`, and `"sideEffects": false` on the packages whose modules are pure declarations. ## Why not the alternatives [#why-not-the-alternatives] **"Just be careful."** Already tried; the comment in `db/schema/todo.ts` explicitly invited the UI to share the constant, and the leak followed. A convention with no failing build is a convention that decays. **"Rely on tree-shaking."** It is what we were relying on, and it delivered 57 KB of DDL to anonymous visitors. `"sideEffects": false` improves the odds but still asks a bundler to prove absence of side effects across every future dependency version. We want the import to be _impossible_, not _usually elided_. **"Split every server package into `@scope/x-server` and `@scope/x-client`."** Twenty packages become forty, and the boundary moves from a lint rule into `pnpm-workspace.yaml`. Subpath exports draw the same line inside one package for none of the churn. **"Ban barrels everywhere, including UI."** Overshoots. `apps/mobile/src/design/index.ts` and `packages/errors` aggregate client-safe modules and nothing can leak through them. The rule is about _reachability of server capability_, not about the `index.ts` filename. ## Consequences [#consequences] - Importing a domain enum now costs an extra hop through a leaf module. That is the point: the hop is where the boundary lives. - `packages/api`'s `"."` entry is gone from the exports map (nothing imported it) and the `"./*"` wildcards on `api`, `auth` and `db` are enumerated. Adding a new cross-package import now means adding an export entry — a deliberate act, reviewable in a diff. - `apps/web/src/utils/orpc.ts` keeps its value import of `appRouter`; SSR genuinely needs the value. It is the single sanctioned exception and is carved out by path in the lint config, so it reads as a decision instead of as an accident. - A leaf module cannot grow. The moment someone adds an import to `schema/todo.enums.ts`, the boundary is gone and no test fails. Reviewers must treat "this file imports nothing" as the invariant it is. - Bundle forensics is the regression test: build `apps/web` and grep `dist/client` for `drizzle:entityKind`, `impersonated_by`, `todo_userId_idx`, `feature_flag`, `HYPERDRIVE`. All must be absent. ## Related [#related] - The reference implementation predates this ADR: `packages/flags/src/index.ts` deliberately omits `./repo` with a comment explaining why. That is the pattern, generalized. - `docs/error-handling.md` already drew the same line for `packages/errors` (`"."` client-safe, `@orpc/server` quarantined in `/server`). - ADR-0001 for the storage package's key rules; this ADR removes that package's re-export of `./config` constants. # Organizations are the tenant The repository's architectural decision, rationale and consequences. # Organizations are the tenant **Status:** accepted · **Date:** 2026-09-17 The template is a SaaS starter on one Neon database. Several people need to share todos and files without seeing each other's work. We use Better Auth's organization plugin (`organization` / `member` / `invitation` + `session.activeOrganizationId`) instead of a custom tenant table. Shared schema, row-level `organizationId`. Teams and dynamic org roles stay off. We already generated these tables once and dropped them as unused (`20260825200958`). This time the plugin is wired, domain tables are scoped, and signup creates a personal Organization. Platform admin (`user.role`) stays global. Org roles do not grant `/admin`. Files live on Cloudflare R2. The Worker credential is one bucket (`STORAGE_BUCKET`). Isolation is the server-enforced prefix `orgs//` plus a membership check (`getActiveMember` reads `member`; the 5-minute cookie cache is not authorization). The client cannot supply or escape the prefix. Never fall back to `users//`. Minting a bucket per Organization would 500 with a bucket-scoped R2 token. Neon stays one shared database with row-level `organizationId`. # Payments: one entitlement read model, three provider modules The repository's architectural decision, rationale and consequences. # Payments: one entitlement read model, three provider modules **Status:** accepted · **Date:** 2026-10-12 The template already verified RevenueCat purchases on the server (`billing_event`, `billing_refresh`, `billing_entitlement`, pull reconciliation). The web app needed Stripe and Polar checkout, and the mobile app never registered the RevenueCat SDK. Each provider must be optional, everything off by default, and the app must boot and pass its tests with no payment keys. ## Decision [#decision] **One read model.** `billing_entitlement` is the only thing that answers "does this User have Paid Access". It gained a `provider` column (existing rows are `revenuecat`) and its key became `(user_id, provider, entitlement_id)`. Each provider replaces only its own rows: RevenueCat reconciliation deletes `provider = 'revenuecat'`; a web event rebuilds that provider's rows for that User. `billing.status` projects all rows of enabled providers in the current `BILLING_ENVIRONMENT`, so a Stripe purchase unlocks the mobile app and a store purchase shows on the web Billing page. RevenueCat rows also expire when their verification is older than `BILLING_MAX_VERIFIED_AGE_SECONDS`; web rows are push-updated and get a 24-hour grace after the period end so a late renewal webhook does not cut access. **Per User, not per Organization.** CONTEXT.md defines an Entitlement as belonging to a User, and Paid Access as not granting Organization membership. ADR-0003 makes Organizations the tenant for shared data, not for billing. Organization billing (seats, one payer per Organization) is a follow-up that would add a reference column, not a second model. **Webhooks are notifications, verified before parsing.** `/api/billing/stripe` and `/api/billing/polar` are Worker routes. They bound and read the raw body, verify with Web Crypto (`crypto.subtle.verify`, constant time), then dedupe on `billing_event (provider, id)` inside the same transaction that applies the change. A replay is a no-op; a failed write rolls back the event row so the provider retries. Stripe signs `${t}.${body}` with the whole `whsec_…` string; Polar follows Standard Webhooks and both key derivations it has used are accepted. Stripe does not order events, so the handler re-reads the subscription from the API (`Stripe-Version` pinned) and fences on that read time; Polar payloads carry `modified_at`, which fences directly. Every applied change queues a `billing.subscription_changed` audit event targeting the User. **No Better Auth payment plugins.** `@better-auth/stripe` 1.7.7 and `@polar-sh/better-auth` 2.1.0 are compatible with better-auth 1.7.7 and contain no Node-only code. We still do not use them: each adds its own subscription state (a `subscription` table and `user.stripeCustomerId`, or Polar's customer sync) that would compete with `billing_entitlement`; registering them conditionally changes the inferred auth type that `createAuth` exports; and they bring the `stripe` and `@polar-sh/sdk` packages into the Worker for five REST calls. The provider modules are fetch-based (`packages/api/src/features/billing/billing.{stripe,polar}.ts`) and run through the same bounded, redirect-free request helper as the RevenueCat client. **Configuration.** `BILLING_PROVIDERS` lists the enabled providers (`stripe,polar,revenuecat`); it replaces `BILLING_ENABLED`, which only meant RevenueCat. Each listed provider is validated as a whole and errors never echo values. Stripe keys must match the environment (`sk_test_`/`rk_test_` for `SANDBOX`, `*_live_` for `PRODUCTION`); Polar uses the sandbox API in `SANDBOX`. The plan catalog (`packages/api/src/features/billing/billing.plans.ts`) is a leaf module shared with the web client; provider price/product ids come from env (`STRIPE_PRICE_IDS`, `POLAR_PRODUCT_IDS`) because they differ per mode. `BILLING_CHECKOUT_PROVIDER` picks the web checkout when both web providers are on (for example while migrating), otherwise Stripe wins over Polar. **Visibility is a flag, capability is env.** `billing.web` shows the Billing navigation entry and `billing.mobile` shows the subscription screen. A flag cannot enable a provider the server has not configured, and a configured provider stays invisible until its flag is on. The mobile app registers `react-native-purchases` through a lazy loader, so the SDK only initializes when billing is used and an older dev client degrades to "store unavailable". ## Consequences [#consequences] - Checkout and portal URLs are built on the server from `BETTER_AUTH_URL` (or the request origin); the client never supplies a redirect target. A second checkout for an active entitlement is refused; plan changes go through the provider portal. - Web subscriptions are only as fresh as their webhooks plus the grace window. There is no scheduled pull for Stripe/Polar yet; `billing.sync` only reconciles RevenueCat. - `scripts/remove-server-billing.mjs` removes all three providers, the Billing page and its navigation together. - Removing a provider from `BILLING_PROVIDERS` hides its entitlements immediately but keeps its rows, so turning it back on restores access without data loss. # Effect stays at the server edges The repository's architectural decision, rationale and consequences. # Effect stays at the server edges **Status:** accepted · **Date:** 2026-10-12 · **Amended by:** [ADR-0010](0010-procedures-run-one-effect-program.md) (a procedure may run one Effect program at its edge) **Enforcement:** `apps/web/scripts/assert-client-bundle-clean.mjs` refuses Effect in client assets; timeout tests use `@effect/vitest` and `TestClock` with production values. Effect 4 entered the template as a local utility: fourteen server files, each running its own `Effect.run*`, with spans nobody exported. The cron handler fanned out five jobs behind a shared `AbortSignal`, and every job noticed cancellation on its own. Three copies of a `Retry-After` parser had two different caps, and no transient failure was retried inside a round. The question was how far to take Effect: keep it local, coordinate the edges, or make the backend Effect-native. ## Decision [#decision] **Edges only (depth N1).** Effect runs where the server executes work it owns end to end: the scheduled jobs and the outbound transports (webhook POST, Expo push, RevenueCat, email), plus the diagnostics probe that already used it. Public contracts stay Zod, requests stay oRPC, authentication stays Better Auth, database access stays Drizzle. oRPC handlers do not become Effect (`@orpc/experimental-effect` is beta on a beta), and no Context.Service/Layer refactor happens without a new ADR. **Server only.** No client tree imports `effect`. `apps/web/scripts/assert-client-bundle-clean.mjs` fails the build if `effect/Effect` reaches a client asset, and ADR-0002's lint keeps server modules out of client imports. **Stable APIs only.** Use modules and functions marked `@stability stable` in the installed package (`Effect`, `Queue`, `Stream`, `Schedule`, `Cause`, `Exit`, `Tracer`, `Duration`, `Option`, `Result`, `Schema.TaggedError`, `effect/testing` `TestClock`). Since 4.0.2 an `unstable` module may break in a minor release; adopting one (`http`, `rpc`, `workflow`, `cluster`, `persistence`, `observability` OTLP…) needs its own ADR. [ADR-0006](0006-effect-persisted-queue.md) restricts native SQL/persistence to a workerd spike; production imports use the stable modules and Drizzle. **One `run*` per entry point.** The cron tick is one program, `scheduledJobs` in `packages/api/src/platform/jobs/run.ts`, run once by `runScheduledJobs` from `scheduled()`. Its jobs return `Effect` and never run themselves. A transport exposes its Effect (`webhookAttempt`, `sendExpoPushEffect`, `verifyRevenueCatEffect`, `sendEmailEffect`) and a thin Promise facade for callers outside Effect (oRPC routes, Better Auth, tests); the facade is the only `run*` on that path and goes through `runEdge` (`packages/api/src/platform/effect/edge.ts`), which maps interruption to the transport's `cancelled` outcome. **Typed errors are converted once, at the edge.** Inside a program failures are `Schema.TaggedError`s. They become the transport's result union, a persisted row or an `ORPCError` in one `catchTags` where the program meets the outside, not in every helper. Interruption is never caught by hand (`Cause.hasInterrupts` lives in `runEdge` only). A rejection that must not become a defect uses `tryPromise`, or `drained` when the promise owns database state: `drained` aborts the signal and waits for the promise to settle, so a scope never closes the pool under a running transaction. **One deadline, one pool, independent jobs.** `scheduledJobs` acquires the job pool with `acquireRelease` in the program scope, runs the six jobs with `Effect.all({ mode: "result" })` (a typed failure or a defect in one never interrupts the others), then runs document indexing after those jobs settle, and applies one `Effect.timeoutOption` of 25 s to the whole tick. Documents have their own 15 s budget, bounded by the remaining tick deadline. [ADR-0008](0008-documents-and-large-uploads.md) records the memory gate and ordering. The deadline interrupts every job; interruption reaches in-flight fetches through the signal `tryPromise`/`drained` pass on; the pool closes last, on success, failure or interruption. Imports reserve cleanup time with a 15 s work budget. The job pool bounds lost query responses with a 2 s client query timeout; the [fault measurement](../measurements/2026-10-13-persisted-queue-spike.md) explains why a SQL statement timeout alone was insufficient. **Retries only for idempotent reads.** `idempotentRead` (`packages/api/src/platform/effect/retry.ts`) is exponential backoff with jitter, two retries inside a budget, honouring a `Retry-After` up to 2 s and handing a longer one back to the persistent policy. It applies to the RevenueCat subscriber GET, Expo `getReceipts` and import source GETs. A webhook POST and an email send are never retried in-round: a repeated write can duplicate a side effect, so they make one attempt and the outbox/lease policy decides. `retryAfterMs` is the one `Retry-After` parser, capped at 24 h. **Zod for third-party payloads.** Provider responses (RevenueCat, Expo) are validated with Zod, the same library as the contracts; a parse failure enters the error channel through `Effect.try`. Effect Schema is used for `TaggedError` only. **No I/O in a module-level runtime.** On Workers a socket belongs to the request that opened it; a `ManagedRuntime` or `Layer` built at module scope that opens a pool fails on the next request with "Cannot perform I/O on behalf of a different request". A module-level runtime may only hold pure services (a tracer, validated config). Anything with I/O is created per invocation, as the job pool is. **Native tracing.** Effect spans go to Cloudflare's own tracing, not to an OTLP exporter. `workersTracer` (`packages/api/src/platform/effect/tracer.ts`) opens a `tracing.startSpan` per Effect span, copies scalar attributes, records failures and ends it; `runScheduledJobs` runs the tick inside `tracing.enterSpan("jobs.scheduled")`. The tracing API comes from `ctx.tracing` in the handler, typed structurally, so no module imports `cloudflare:workers` and the code runs untraced in Node (dev, vitest, `jobs:run`). `observability.traces.enabled` is on in `apps/web/wrangler.jsonc`. ## Consequences [#consequences] - Cloudflare's API has no explicit parent and `startSpan` never becomes active, so the hierarchy is flat: every Effect span is a sibling under `jobs.scheduled`, next to the platform's fetch and Hyperdrive spans. There is no `setStatus` yet; failures show as recorded exceptions and interruptions as `effect.interrupted = true`. - Timeout tests use `@effect/vitest` and `TestClock` with the production values (10 s per transport, 25 s per tick) instead of waiting a few real milliseconds. - When the deadline hits, the tick reports `interrupted: true` with empty counters; the work itself is persisted row by row, and the job spans carry what each job did. - The cost is a server chunk evaluated at every cold start; `docs/measurements/2026-10-12-effect-edges.md` records its size. Splitting it with a dynamic `import()` is against the repo rules and was not done. - Going further (Effect-native handlers, services per request) needs a new ADR and one of these triggers: oRPC 2 and `@orpc/experimental-effect` stable; several handlers composing I/O with retry and timeout; or a startup measurement showing the chunk is free. # Todo imports and the PersistedQueue gate The repository's architectural decision, rationale and consequences. # Todo imports and the PersistedQueue gate **Status:** accepted, with native persistence rejected for production · **Date:** 2026-10-13 **Enforcement:** the workerd fault gate `scripts/test-persisted-queue-spike.mjs` before any change to queue persistence, pins or deadlines. ## Decision [#decision] Use stable Effect `Queue`, `Stream`, `Schedule` and the existing scoped Drizzle job pool to execute todo imports. Persist claims in `import_queue` with `FOR UPDATE SKIP LOCKED`, a random lease token and a lease expiry. The cron tick is the sole production executor. `imports.start` records intent; it never starts background work in an HTTP request. Keep one Worker, the existing cron trigger, Postgres through Hyperdrive and R2. This change creates no Cloudflare Queues, Durable Objects, Workflows or KV resources. The requested native implementation failed the shutdown gate in actual workerd. `@effect/sql-pg` connected, committed and rolled back transactions; two independent consumers claimed 40 distinct items once each. However, a lost acknowledgement response blocked a store finalizer beyond the 25 s tick deadline. The query stayed pending after 27 s with two connections open. The [measurement report](../measurements/2026-10-13-persisted-queue-spike.md) and `scripts/test-persisted-queue-spike.mjs` record the reproduction. ## Restricted unstable-module exception [#restricted-unstable-module-exception] ADR-0005 permits `effect/persistence`, `effect/sql` and `@effect/sql-pg` only in `packages/api/scripts/persisted-queue-spike.ts`. They are not imported by production code. The workspace catalog pins `effect`, `@effect/vitest` and `@effect/sql-pg` to exactly `4.0.2`; API consumes the SQL package as a `catalog:` development dependency. The normal 2,880 minute release quarantine applies without an exception. The spike constructs `PgClient.layer`, `PersistedQueue.layerStoreSql` and `PersistedQueue.layer` inside each invocation and scopes their lifetime to that invocation. Native adoption would require a second scoped pool, exclusively for queue metadata. Drizzle would continue to own domain transactions. Sharing a connection or transaction across the two libraries would obscure commit ownership and is outside this decision. The shipped fallback uses only the existing Drizzle pool. Native store migrations create `effect_queue_spike` and its migrator journal only in the disposable test database. Production uses journaled Drizzle migrations for `import_run` and `import_queue`; it has no `effect_queue` table. A future native adoption must ship the exact native migrations and journal before deployment, and prove that `layerStoreSql` accepts that journal. Creating just a matching table would make the Effect migrator see an old version. ## Delivery and domain consistency [#delivery-and-domain-consistency] `imports.start` requires current membership, an already confirmed upload in the active Organization's prefix, and a UUID `requestKey`. The unique pair of Organization and request key returns the same run on retries. Reusing it for another source or actor is a conflict. The run freezes the uploaded file's key and ETag. Each tick publishes up to 25 unpublished runs in a transaction, then processes at most 25 eligible claims with two concurrent consumers. The local Effect queue contains run IDs; Postgres owns delivery state. Claims expire after 60 s. Every domain commit locks the queue row and checks both its lease token and expiry. An expired or superseded consumer cannot write todos. The worker reads at most 1 MiB, including a stream byte count independent of Content-Length, and validates an array of 1 to 200 todos with Zod. It compares the ETag returned by the same GET as the body. Immediately before writing, the transaction rechecks membership and the confirmed upload under shared locks. The installed R2 adapter does not implement `conditional.exactRead`, so the GET's ETag and the transaction's upload record provide fencing instead. A transaction advisory lock serializes position allocation between imports of the same Organization. Manual creation, proposal application and interactive moves do not take that import lock. They retain the existing ID tie-break and lane renumbering behavior; concurrent non-import writes can interleave positions without losing todos. Inserting all todos and setting `import_run.state = 'succeeded'` happen in the same Drizzle transaction. Acknowledgement is a later operation. Delivery is at least once; a replay of a terminal run acknowledges it without reading the file or inserting again. A response lost after commit has the same safe replay behavior. Revoked membership, a changed or missing source, malformed JSON and an oversized payload produce persisted failure reasons. Transient storage or database failures defer the claim. Three claims exhaust the run; a tick also reconciles an expired final claim after a crash. Retryable reads use `idempotentRead`; no non-idempotent send is retried. A failed run is the dead-letter record, visible through the same org-scoped API. Retrying a terminal failure is an explicit new import with a new request key. Acknowledged queue rows expire after 30 days, with deletion bounded to 25 per tick. Import runs, counts, reasons and request keys remain until the Organization is deleted. This preserves start idempotency and requires a future product decision if long-term run storage becomes material. Uploaded source files follow the existing Storage lifecycle. Import runs are their own history; imported todos currently do not emit the interactive router's per-todo `todo.created` audit events. ## Deadlines and ownership [#deadlines-and-ownership] The tick remains one Effect program, one `runEdge`, with Cloudflare's `workersTracer`. The import job stops admitting work after its 15 s budget; each source read has a 10 s timeout. Database promises use `drained` so the scoped pool closes after transactions settle. Typed import failures become persisted outcomes once, in `processImport`'s `catchTags`. The job pool now uses node-postgres `query_timeout: 2000` in addition to server `statement_timeout: 5000` and the existing 5 s connection timeout. A server-side statement timeout cannot stop a query whose packet or reply is lost. The client timeout bounds that wait. The acknowledgement uses `pool.query`, which releases the connection with the error and removes it. A transactional PoolClient can instead time out again during rollback; the tick drains that promise and closes the scoped pool. The workerd fault test also drops the success update inside a Drizzle transaction: the tick returns HTTP 500 in 6,027 ms, rolls back every todo, closes all job connections, and succeeds on the next tick. Draining the current query and rollback has room within the import job's 10 s cleanup reserve. Pool acquisition and network faults across all six jobs still require monitoring; the 25 s Effect deadline alone is not a wall-clock guarantee for arbitrary external cleanup. No pool, storage client with I/O, shared Layer or ManagedRuntime is opened at module scope. Interruption leaves a lease for a later tick instead of retrying acknowledgement in an uninterruptible finalizer. ## Compatibility and rollback [#compatibility-and-rollback] Reconsider native persistence only after the pinned version passes transactions, independent concurrent consumers, replay, migrations and per-invocation connection closure in built workerd, including a lost acknowledgement response within 25 s. An outer Effect timeout, a short native lease and a SQL statement timeout did not meet that criterion. Any version change repeats the gate and records bundle cost. The Drizzle migration is additive. Rolling application code back leaves pending imports durable and stops their execution; it does not delete completed todos. Resume with compatible code before dropping tables. Removing imported todos is a separate user action, never a queue rollback. A future queue implementation must preserve the run's request-key and atomic domain-commit rules. Neither a dependency update nor this ADR authorizes a new Cloudflare resource. # AI assistant and one tool catalog The repository's architectural decision, rationale and consequences. # AI assistant and one tool catalog **Status:** accepted · **Date:** 2026-10-14 ## Decision [#decision] Use [TanStack AI](https://tanstack.com/ai/latest/docs) for the authenticated web assistant: `chat()` streams from the Worker over SSE, `useChat` consumes it, and [`@tanstack/ai-persistence`](https://tanstack.com/ai/latest/docs/persistence/chat-persistence) stores messages through a Drizzle message store. Keep one Worker, Postgres through Hyperdrive, and R2. No Workers AI binding, Durable Object, Queue or KV is added. TanStack AI provides the required provider adapters, typed tools, approval/input interrupts, streaming client and persistence middleware in one protocol. Effect AI would introduce another model/tool protocol and additional unstable Effect modules beyond [ADR-0005](0005-effect-boundary.md). Vercel AI SDK also supports this class of application, but using it alongside TanStack AI would duplicate the chosen tool and message contracts. Neither alternative supplies a needed capability missing from this implementation. Keep the existing `@modelcontextprotocol/server` transport and Better Auth MCP OAuth. Adapting the catalog preserves the in-process JWKS lookup, token revocation guards, scope challenges and dual MCP Apps/OpenAI widget resources. A transport migration offers no benefit that justifies changing those contracts. TanStack managed/manual MCP clients, remote MCP tool loading, server tasks and server sessions are not needed for this local catalog. ## One catalog and authorization [#one-catalog-and-authorization] `packages/api/src/features/agent-tools/agent-tools.contract.ts` defines every `toolDefinition` with Zod input/output contracts. `agent-tools.catalog.ts` binds the server implementations. The assistant passes these tools to `chat()`; `features/mcp/mcp.server.ts` adapts the same entries to MCP. Tool implementations call the existing oRPC/domain operations instead of copying todo, file or import SQL. Client code imports leaf-safe contracts and types, never this server catalog. [ADR-0002](0002-no-barrels-on-server-capable-modules.md) still applies. The catalog has 15 tools, including the read-only `document_read`. It takes a confirmed `fileId`, an optional chunk cursor and a page limit. The default page contains two chunks and the maximum contains four, each at most 8 KiB. `search` includes ranked matches from converted document chunks; `fetch` reads the matching chunk offset and returns `nextCursor` for continued reading. `files_list` exposes each confirmed file's ID and conversion status alongside its metadata. Original file bytes never enter tool results. The assistant and MCP use the same Organization-scoped document operations described in [ADR-0008](0008-documents-and-large-uploads.md). `template://organization/documents/{fileId}` returns the first bounded JSON page, with `nextCursor` for `document_read`. Resource reads pass through the same membership and MCP read guards as tools. The `plan-from-document` prompt asks the agent to read relevant pages, identify assumptions and call the existing `draft_task_plan`. Tool approval creates an inert proposal; the User must separately Apply it to create Todos. Source text is untrusted data. The assistant instructions forbid following instructions inside documents and require it to disclose when a response uses only part of a document. Every call checks current membership. The assistant uses the Session's Active Organization and rejects a change of Organization during a turn. An MCP token carries no Active Organization, so its context stays pinned to the User's personal Organization. OAuth read/write scopes and token revocation remain additional MCP checks. Every write has `needsApproval: true`. Approval is a persisted record bound to the User, Organization, tool name and parsed arguments. Assistant approvals also bind the Thread and tool-call ID. Client-supplied approval booleans cannot independently authorize a write. The execution transaction locks current membership and the approval, checks its expiry and exact arguments, applies the domain operation, records the audit event, and stores the result in the approval ledger. Replaying an executed approval returns that result without repeating the mutation. MCP writes return a pending approval and a link to Integrations. The User approves or denies there. Legacy draft tools remain available; approving creation of a proposal still leaves its separate Apply step. MCP approval records expire after 15 minutes and admission allows at most 20 unexpired pending records per User. Tool audit metadata identifies the tool and Organization without copying prompts, messages or credentials. ## Promise and streaming boundary [#promise-and-streaming-boundary] AI calls are outbound transport, but this ADR makes a narrow exception to ADR-0005: TanStack AI owns its native Promise/async-iterator lifecycle behind `packages/api/src/features/assistant/assistant.http.ts`. Wrapping `chat()` in Effect would introduce a second cancellation and finalization owner around SSE, interrupts and persistence. Existing scheduled jobs and other outbound transports retain their Effect boundaries. The HTTP boundary owns the abort controller, request deadline, bounded JSON input, rate limit, authentication and pool lifetime. It creates one bounded pool with `createJobDb` and creates Better Auth with the current request and that database. The SSE iterator's `finally` releases the Thread lease and closes the pool; requests that fail before streaming close it in the outer `finally`. Ordinary thread-management oRPC procedures retain the existing request scope. Nothing opens a pool or runtime with I/O at module scope. `withPersistence` writes streaming snapshots at most once per second and saves the completed transcript on finish. Stop, timeout and disconnect abort the current model run; they recover the last successful snapshot rather than flushing every partial token. Reloading reads stored messages and pending approval/input interrupts. There is no background execution, persisted stream log or resumable transport after disconnection; a later request starts or continues a turn from the saved state. A lost connection can therefore lose text emitted after the last successful snapshot. The approval ledger protects completed writes independently of message persistence. ## Configuration and cost [#configuration-and-cost] Follow [ADR-0004](0004-payments-providers.md): capability comes from server configuration, visibility from a flag. `AI_PROVIDER` and its matching nonempty secret must be configured, and `ai.assistant` must evaluate true. Otherwise the assistant route/API is unavailable and navigation is hidden. This gate does not disable the existing MCP server or its approval flow. `AI_MODEL` overrides the provider default. Defaults are Anthropic `claude-sonnet-5-5`, OpenAI `gpt-6-astra`, and Gemini `gemini-4-argon`. The permitted package family predates these model IDs, so the documented [`createModel`/`extendAdapter` API](https://tanstack.com/ai/latest/docs/advanced/runtime-adapter-switching) accepts the selected text model without unsafe casts or bypassing release quarantine. Keys never become client configuration. Defaults are five model/tool steps, 2,048 output tokens per model call, a 60-second abort deadline, and ten requests per minute per User. The rate limit reuses the Postgres-backed limiter. Input is capped at 16 KiB and one user message of 8,000 characters; message history and stored snapshots are bounded separately. Model turns and write tools have no application-level automatic retry. These limits bound individual requests, not monthly spend; deployments still need provider budget controls. The deterministic adapter requires both an `ai-test` build and `AI_TEST_PROVIDER=true`. A normal production build cannot select it, even if that environment variable is set. Tests use isolated local Postgres and this adapter, never a live model by default. ## Persistence and public discovery [#persistence-and-public-discovery] Threads belong to one User and one Organization. They retain titles, messages, tool results and pending interrupts until the User deletes the Thread or the User/Organization is deleted through foreign-key cascades. There is no automatic age-based deletion. Thread-linked approval records cascade with the Thread; standalone MCP approvals retain their records until User/Organization deletion. Expiry prevents execution but does not delete the ledger. Provider-side retention is separate from this application's database retention. Reading a document in an assistant conversation sends the selected text pages to the configured model provider and stores the tool results in the Thread. MCP clients receive only the pages they request. Conversion runs in a browser Web Worker and does not call a hosted model or Firecrawl API. `awaitingBrowser` tells agents when Files must complete conversion before text is available. Organization membership governs document access; a document cannot grant access to another Organization by naming it in its text. The additive schema uses the journaled `db:apply-pending` flow. A Thread lease permits one active request; stale leases expire. Disabling the provider or flag preserves history. Deleting a Thread does not undo domain changes already made by approved tools. Public `/llms.txt`, `/llms-full.txt` and Markdown pages derive from the landing, legal content, demo posts/profiles and connection guide. They expose no private Thread or Organization content. Sitemap, robots and alternate links distinguish public documents from authenticated routes. Existing OAuth discovery endpoints remain authoritative. No server-card endpoint is advertised: the official [`ext-server-card`](https://github.com/modelcontextprotocol/ext-server-card) work is experimental rather than a stable MCP discovery requirement. Version selection, quarantine exclusions rejected, bundle measurements and verification evidence are recorded in [the measurement report](../measurements/2026-10-14-ai-assistant.md). Setup and client connection instructions are in [Connect your agent](../connect-your-agent.md). The Worker enables `enable_request_signal` so client disconnects reach the model abort controller. Its outer `fetch` passes Cloudflare `waitUntil` into the request scope before delegating to TanStack Start, whose two-argument wrapper otherwise drops the Worker context. A completion promise keeps lease release and pool closure alive after cancellation, within Cloudflare's cleanup window. See [Request.signal](https://developers.cloudflare.com/workers/runtime-apis/request/) and [waitUntil](https://developers.cloudflare.com/workers/runtime-apis/context/). # Documents and direct uploads The repository's architectural decision, rationale and consequences. # Documents and direct uploads **Status:** accepted · **Date:** 2026-10-15 Files upload directly from the browser to R2. A dedicated browser Web Worker converts supported files with `@firecrawl/anydoc-wasm` **0.2.4**. The existing cron worker validates and indexes the resulting Document Text. Large originals remain usable even when they exceed the conversion cap. We selected the browser fallback after the built server Worker failed the memory gate. A batch reached **150.48 MiB**; reducing the batch to one conversion per tick still reached **142.35 MiB** across repeated ticks. Fresh WASM instances were collectible, but ordinary garbage collection did not reclaim them soon enough to meet the 128 MiB isolate limit. Bundle size and startup passed. [The measurement report](../measurements/2026-10-15-anydoc.md) preserves the rejected server experiment and the final browser results. ## Direct byte transport [#direct-byte-transport] `uploads.prepare` checks membership, MIME type, and size before creating an upload intent. A signed PUT covers the exact size, type, key, and `If-None-Match` condition. Files above 16 MiB use S3 multipart with 8 MiB parts. The browser sends at most three parts concurrently, tries each part at most three times, and resumes from server-listed parts. It reads `File.slice()` objects rather than allocating the whole file. The Worker signs requests with `aws4fetch` **1.0.20** and validates completion against the expected part lengths and ETags. Intent IDs and keys belong to one Organization. Signed URLs expire after 15 minutes. Intents last 24 hours; cron confirms completed objects through HEAD when the browser loses its acknowledgement. Explicit cancellation aborts multipart and invalidates recovery leases. Single-PUT cancellation keeps a 16-minute cleanup window because an already issued URL can still finish. Cron also aborts abandoned multipart sessions after 24 hours, with bounded pagination and work per tick. The leaf `packages/storage/src/config.ts` configures storage limits: images 100 MiB, documents 1 GiB, and ZIP archives 5 GiB. The legacy proxy stays capped at 10 MiB. Downloads use the existing gateway's signed redirect or streamed response. Text previews read at most 64 KiB and cancel the reader even when an origin ignores Range. Large images are not fetched as thumbnails. PDF viewers and downloads can issue range requests against the original object. ## Bounded conversion in the browser [#bounded-conversion-in-the-browser] The Node `@firecrawl/anydoc` package depends on napi and cannot run in workerd. The unrelated npm package `anydoc` is not used. The WASM package runs without an external conversion service and supports Office, OpenDocument, EPUB, RTF, CSV, and PDFs that contain text. It does not provide OCR or a streaming parser. The current caps are **1.5 MiB for PDF**, **10 MiB for other supported inputs**, and **192 KiB of extracted Markdown**. The browser checks the source size before reading it. Resuming conversion downloads the original through a byte-capped streamed reader and verifies its ETag. Encrypted, malformed, unsupported, OCR-required, oversized, and resource-limited documents become permanent failures. A missing converter asset remains resumable. Plain Markdown and text files above the text cap show the limit before the original upload starts. Converted output is checked before `prepareText` or any Markdown PUT. The distinct `text_too_large` failure says that the original remains stored and downloadable but will not be indexed. English, German and French copy interpolate the shared cap. Conversion has a 45-second deadline; cancelling terminates its worker and preserves the uploaded original. A `pnpm patch` adds `createConverter` and `dispose` to the pinned package. Each conversion instantiates a fresh WASM module; `finally` drops its instance, memory views, and reference heap. The main thread terminates the Web Worker after success, failure, or cancellation. Its separate entry is created with `new Worker(new URL(...), { type: "module" })`; WASM is fetched only when conversion starts. There is no dynamic `import()`. Node test helpers load and compile the file through static imports; production server code does not import the converter. Input size alone does not bound ZIP expansion. The build also changes the pinned WASM memory descriptor from an unbounded memory to a maximum of **32 MiB**. `packages/api/src/features/documents/documents.wasm-memory.ts` rejects an unexpected descriptor instead of silently patching a new package layout. An exhausted memory produces a permanent `resourceLimit` result. This bounds WASM linear memory; it does not claim to bound all JavaScript and native allocations in an isolate. The rejected server design required an upload gzip size at most 8.5 MiB, startup below 1 second with margin, and a cap-sized PDF inside the cron budget and 128 MiB isolate limit. Moving conversion back to the server requires passing all three gates, including repeated conversions without test-forced garbage collection. Package upgrades or higher browser caps require another measured browser run. ## Persistence and reads [#persistence-and-reads] Original upload confirmation creates a pending document associated with the confirmed source ETag. The browser converts it, calls `documents.prepareText`, and PUTs Markdown to the immutable hidden key `incoming/orgs//.md`. The signed request fixes its size and content type. `confirmText` checks HEAD metadata, freezes the text ETag, and publishes the indexing job atomically. The server treats client-produced Markdown as untrusted, including when the client is an Organization member: it verifies source ownership/version, exact size, the 192 KiB cap, UTF-8, and the text ETag while reading. C0 controls other than tab and line breaks are rejected before PostgreSQL ingestion. Documents reuse the import workflow's Drizzle outbox, expiring lease, token checks, and atomic commit. The tick first settles audits, imports, upload recovery, webhooks, billing and push through `Effect.all({ mode: "result" })`. Only then does it run document maintenance and indexing, with its own 15-second budget inside the unchanged 25-second tick deadline. There is still one Effect program, one scoped pool and one `runEdge`. A document failure or exhausted budget cannot prevent those earlier jobs from executing. A global timeout before they finish skips documents; indexing gets only the remaining global time. This ordering does not make an isolate OOM recoverable, but earlier jobs have already settled when indexing starts. Stable Effect Queue and Stream process one document per tick. The cron downloads only Markdown, cutting chunks as UTF-8 arrives instead of joining a second full decoded string. It retains the bounded chunk collection for the atomic commit. Each insertion binds four PostgreSQL arrays of at most 32 chunks with `unnest`, avoiding JSONB's extra escaping and limiting protocol allocations. R2 promotes the immutable staged object through a native copy under the lease lock, without a Markdown upload body. A stale worker cannot commit chunks or overwrite newer text. Recovery and replay do not duplicate chunks. With the one-minute schedule, a backlog takes multiple ticks to index. The default-runtime gate now asserts at most 110 MiB across the baseline, tick samples and final observation, with repeated maximum-size submissions and no forced collection. The 2 MiB, 1 MiB and 512 KiB candidates failed; 256 KiB also failed one of three repeats at 110.91 MiB. The largest tested cap that passed every repeat was 192 KiB, with peaks of 108.85, 108.78 and 108.82 MiB. The measurement report preserves every attempt. These are local inspector samples, not a continuous production memory bound. The old 96 MiB V8 diagnostic is not a gate result. The journaled limit migration fences oversized pending submissions and marks them `text_too_large`. It clears obsolete staging size/version metadata before tightening the database check. Original files, existing chunks and ready derived text remain intact; staged objects retain their cleanup deadline. Staged text is hard-deleted by cron after its recorded 16-minute upload deadline, once indexing finishes or an unconfirmed upload expires. Waiting until the signed URL expires also covers late PUTs. Confirmed pending text stays available for lease recovery. Unsubmitted documents remain visibly waiting for browser conversion; the preview can resume preparation from the stored original. Uploads made through external clients, including MCP, need this browser step before document tools can read their text. Full Markdown lives at `derived/orgs//.md`, outside the user-facing `orgs/` namespace. Heading-based chunks target 4 KiB and never exceed 8 KiB. Postgres ranks matches through a GIN index on `to_tsvector('simple', ...)`. `document_read` returns two chunks by default and at most four, with an explicit next cursor. Preview components retain only the current page. MCP `search`, `fetch`, and the document resource use these same authorized reads. Document deletion and source changes lock the matching upload intents, invalidate searchable text, and fence indexing leases before the original storage operation. This also serializes deletion with upload confirmation, which may still be creating the document row. Derived writes share the database lease lock with this invalidation. Invalidation hard-deletes staged and derived text without placing another copy in `.trash`; if deletion fails, the original operation fails and can be retried. A rolled-back derived write can leave inaccessible text until indexing retries or the original is deleted. Restoring a trashed source HEAD-confirms its current version and prepares a new browser conversion. ## Privacy and assistant behavior [#privacy-and-assistant-behavior] Conversion runs locally in the browser. Original bytes and Markdown go only to the configured R2 endpoint and the application's Worker. There is no Firecrawl hosted API, Workers AI, new R2 binding, Queue, Durable Object, or KV resource. Original files remain under the existing trash policy. Extracted text is untrusted: the preview skips raw HTML, suppresses remote images, and uses safe Markdown URL handling. Document text cannot grant tool permissions. Only current Organization members can read chunks. The assistant also requires its existing provider configuration and feature flag. Reading a document in the assistant sends the returned chunks to that configured model provider. The MCP `plan-from-document` prompt reads pages and calls `draft_task_plan`; the resulting proposal remains inert until the user applies it. Deterministic spreadsheet-to-Todo import is deferred; the existing JSON import remains unchanged. # Row-level security isolates Organizations The repository's architectural decision, rationale and consequences. # Row-level security isolates Organizations **Status:** accepted · **Date:** 2026-10-16 **Enforcement:** `packages/db/src/rls-catalog.test.ts`, `db:verify-rls` at the end of `db:apply-pending`, the `rls-matrix` CI job, `scripts/check-system-call-sites.mjs` in `pnpm lint`, and the Hyperdrive and production-config checks in the deploy workflow. Until now, tenant isolation ([ADR-0003](0003-organizations-are-the-tenant.md)) lived only in application filters: every query carried `where organizationId = …`. One forgotten filter would leak another Organization's rows. PostgreSQL row-level security now enforces the same boundary in the database, and the filters stay as defence in depth. ## Postgres is generic; Neon is only a host [#postgres-is-generic-neon-is-only-a-host] The design uses standard PostgreSQL and nothing host-specific: no `neondb_owner`, no `drizzle-orm/neon` (`authUid`, `crudPolicy`), no Neon roles or extensions. It works the same on local Docker or Homebrew Postgres, Neon, Supabase, RDS or self-hosted servers, through Hyperdrive or a direct `pg` connection from `DATABASE_URL`. Migrations never name the connection role; they use `CURRENT_USER`. **Minimum version: PostgreSQL 16.** The connection role is granted `app_tenant` `WITH INHERIT FALSE, SET TRUE`, which is PostgreSQL 16 syntax. The foundation migration checks `server_version_num` and stops with a clear message on 15 or older. A fallback for 14/15 (a plain `GRANT`, which makes the role inherit `app_tenant`) was rejected: inheritance would let `app_tenant`'s policies apply to the connection role and would leave a second, untested code path. Every current managed host offers 16 or newer. ## Decision [#decision] **Tenant traffic runs as `app_tenant`.** It is `NOLOGIN NOINHERIT NOBYPASSRLS`, owns nothing, and holds exactly the privileges in `packages/db/scripts/rls-baseline.mjs` (UPDATE only on the columns tenant code writes, so authorship cannot be reassigned). A tenant statement runs in a transaction that sets `role` to `app_tenant` and `app.user_id` / `app.org_id` with `set_config(…, true)`, so nothing survives `COMMIT` on a pooled connection (Hyperdrive runs in transaction mode). **Policies live in the Drizzle schema.** Every table is `pgTable.withRLS`; policies use `pgPolicy(…, { to: appTenant })` via the helpers in `packages/db/src/schema/tenancy.ts`, and drizzle-kit generates the SQL. `app.org_id()` returns the Active Organization only while the context User is a Member right now (`SECURITY DEFINER`, fixed `search_path`), so a removed Member sees nothing on the next statement even if the application forgets to check. Policies call them as `(select …)` so each is an InitPlan evaluated once per statement; a row-level membership test compares against `(select app.member_orgs())::text[]`, the context User's current Organizations, never a per-row function call. They are plpgsql because a non-inlinable SQL function is re-planned on every statement before PostgreSQL 18; plpgsql caches the plan per session. Tables tenant code may only lock get an update policy with `WITH CHECK (false)`. Better Auth's tables, OAuth storage, queues and job cursors have RLS with no tenant policy and no grant: default deny. **The connection role is the system path, whatever it is.** It stays the owner of every table. Three profiles are supported and tested: | profile | example | system path sees all rows because | | ------------------------- | ------------------------------------------------- | ------------------------------------------------ | | superuser | stock local `postgres` | superusers bypass RLS | | owner with BYPASSRLS | Neon `neondb_owner` | BYPASSRLS | | plain owner, no BYPASSRLS | RDS master user, Supabase `postgres`, self-hosted | table owners are exempt from RLS without `FORCE` | Tables therefore do **not** use `FORCE ROW LEVEL SECURITY`: with FORCE, a plain owner would be subject to the tenant policies (none of which admit it) and Better Auth, cron and webhooks would see zero rows. Isolation comes from switching to a role that is neither owner nor bypassing, not from FORCE. The runtime role (Hyperdrive's or `DATABASE_URL`'s) must be the role that ran the migrations, or at least own the tables; confirm it with `select pg_has_role(current_user, 'app_tenant', 'SET')` on the runtime connection. **Creating the role.** The migration creates `app_tenant` when the connection role has `CREATEROLE` (managed hosts' admin users do) or is a superuser. Without it, the migration stops and prints the SQL an administrator must run once: ```sql CREATE ROLE app_tenant NOLOGIN NOINHERIT NOBYPASSRLS; GRANT app_tenant TO WITH INHERIT FALSE, SET TRUE; ``` It also refuses an existing `app_tenant` that is SUPERUSER, BYPASSRLS or LOGIN. **Identity is bound to this database's roles.** `app_tenant` is cluster-wide, so any other role that may SET it (the owner of a sibling database after an administrator's GRANT) could connect here and claim any User through the GUCs. The identity functions (`app.user_id()`, `app.org_id()`, `app.is_member()`, `app.member_orgs()`, `app.user_is_active()`; all plpgsql, `STABLE SECURITY DEFINER PARALLEL RESTRICTED` with a fixed `search_path`) return nothing unless `session_user` is listed in `app.session_roles` (`app.session_trusted()`), which migrations seed with `CURRENT_USER`. The tables every User reads (`feature_flag`, `app_update_config`) require `app.session_trusted()` too, instead of `USING (true)`. A runtime role different from the migration role must be added there (`db:verify-rls` says so). A per-database role name was rejected: policies name their role statically, so it would mean generating every policy per database for no extra protection over the allowlist; revoking `CONNECT` from PUBLIC was rejected as host-hostile (managed platforms connect their own roles). **Verified on every apply, repairable after a restore.** The migration journal records what ran, not what survived. `pnpm --filter @template-cloudflare/db db:verify-rls [ENV_VAR]` checks the role, the allowlist, the functions, RLS on every table (and no FORCE), every policy of the latest drizzle-kit snapshot and the exact privilege set; `--repair` re-applies the idempotent baseline and recreates missing policies in one transaction. `db:apply-pending` runs both after migrating and fails with actionable messages otherwise. Moving providers: create `app_tenant` and grant it to the target connection role first if that role cannot create roles, restore the dump (`pg_restore --no-owner --no-acl` loses grants, and loses policies when the role is missing), then run `db:apply-pending` against the target — it repairs grants, functions, allowlist and policies. The RLS matrix proves this with a real `pg_dump`/`pg_restore` from a superuser cluster into a plain-owner cluster. **Entry points.** `@template-cloudflare/db/tenancy` (`packages/db/src/tenancy.ts`) exposes: - `tenantDatabase(systemDb, { userId, organizationId })` — a Drizzle handle whose every statement, and every explicit `transaction()`, is its own short `app_tenant` transaction. It refuses SET, RESET, DISCARD and `set_config()`, and every transaction-control statement except those drizzle's `transaction()` sends; a connection that is not idle when released (pg's ReadyForQuery status) is rolled back and destroyed instead of pooled. All oRPC procedures, the assistant, MCP tools and the files gateway get this as `context.db`. `orgProcedure` narrows it to the Active Organization after the membership check, which is a single system statement (`asSystem(…, "auth.membership")`, user and Organization from the session, never from input); the database re-checks membership on every tenant statement anyway. - `withTenant(db, scope, fn)` — one `app_tenant` transaction, for code that needs it. **One round trip per tenant statement (pipelining).** A standalone statement is sent as Parse/Bind/Execute of the settings (`set_config('role', 'app_tenant', true)`, `app.user_id`, `app.org_id`), then Parse/Bind/Execute of the statement, then a single Sync. Without BEGIN, everything up to the Sync is one implicit transaction: the Sync commits it, any failure rolls the whole of it back and the server skips the remaining messages, so the statement never runs without its identity and nothing survives on the pooled connection. Explicit `transaction()`s pipeline BEGIN with the settings, and SAVEPOINT/RELEASE travel in front of the next statement. Kill switch: `DB_TENANT_PIPELINE=0` (a Worker var or secret, read per handle, no code change) restores BEGIN, settings, statement, COMMIT. `packages/db/src/tenancy-pipeline.test.ts` proves isolation and the absence of role/GUC leaks across statements and pooled connections in both modes, and `test:rls-matrix` runs every suite with the switch on and off. Before production, `node scripts/rls-preview-proof.mjs ` attacks a real deploy behind Hyperdrive (each PR preview gets its own Hyperdrive, so it takes the production connection path) (cross-tenant reads, writes and IDOR, plus a concurrent burst alternating tenants) and exits non-zero on any leak. Because the application filters would hide a broken policy or a missing `SET ROLE` from those attacks, it also calls `tenancy.probe`. The probe exists **only on PR previews**: it is mounted when the Worker has `TENANCY_PROBE=1`, which `scripts/preview-config.mjs` sets for every preview and which `scripts/check-production-config.mjs` refuses in the production config (a deploy step); in production the procedure is absent from the router (404). It answers only booleans about the caller: whether its tenant statement and a `withTenant` transaction ran as `app_tenant`, whether `app.user_id()`/`app.org_id()` are the caller's, whether rows of other Organizations are visible without application filters (one row per Organization table, found as the connection role through the organization_id index and checked by TID, so the cost is constant), and whether a system statement right after finds `app_tenant` or tenant settings left on the connection. A batch carries at most one probe call. The proof was checked against injected faults (a permissive policy, session-level `set_config(…, false)`, a tenant entry without the role switch); each makes it exit non-zero with the cause. - `tenantStep(tx, scope, fn)` — an `app_tenant` savepoint inside a system transaction (the import worker inserts todos this way while holding its lease). - `asSystem(db, reason, fn)` — the only sanctioned way to use the connection role, with an enumerated `SystemReason`. `scripts/check-system-call-sites.mjs` (run by `pnpm lint`) fails when a call site is missing from `packages/db/src/system-call-sites.txt`, and lint refuses `createDb`/`createJobDb` outside the listed entry points. **Same-Organization constraints bind the system path too.** Composite foreign keys keep files, document text, chunks, imports and tool approvals inside one Organization, and CHECK constraints keep storage keys under the row's own `orgs//` prefix. ## Why not the alternatives [#why-not-the-alternatives] - **One transaction per request** (the original plan): it holds a connection across storage and provider calls, and it changes statement semantics — a caught SQL error would abort every later statement of the request. Per-statement tenant transactions keep plain-pool semantics; pipelined, they cost no extra round trip (two extra with `DB_TENANT_PIPELINE=0`). - **FORCE without switching role**: BYPASSRLS ignores it, and on a plain owner it breaks every system path. - **A separate LOGIN role and a second Hyperdrive**: two pools, ownership changes, and Better Auth on another connection, for no extra isolation. - **Session-level `SET ROLE`**: leaks across a transaction-mode pool. - **`drizzle-orm/neon` helpers or drizzle-kit `entities.roles`**: host-specific (Neon Authorize/JWT) or non-idempotent `CREATE ROLE`. ## Consequences [#consequences] - A new table needs `pgTable.withRLS`, its policies and grants, or `packages/db/src/rls-catalog.test.ts` fails. The catalog test also refuses FORCE, policies for any role but `app_tenant`, and any privilege without a matching policy. - A new cross-tenant path needs a `SystemReason` and an inventory line in the same change. The inventory also lists every `systemDb` reference and `createRequestScope()` call, and `asSystem(db, reason, (s) => s)` is refused: the work happens inside the callback. - A new tenant grant goes into `packages/db/scripts/rls-baseline.mjs` and a migration together; `db:verify-rls` (and the catalog test) fail on any difference. - Better Auth, cron, billing webhooks and admin keep running as the owner; their correctness rests on code, the constraints above and the gaps closed alongside this change. - RLS does not survive SQL injection (a `RESET ROLE` returns to the owner). Drizzle parameterises every value; keep it that way. - Under RLS, operators that are not LEAKPROOF in the user's own predicates (`ILIKE`, `LIKE`, full-text `@@`, cross-type timestamp comparisons) can never be index conditions ahead of the policy: GIN/trigram indexes do nothing for tenant traffic. Narrow by an indexed tenant column and keep the filter cheap (e.g. the stored `document_chunk.search` vector). Marking operators LEAKPROOF needs a superuser and weakens the guarantee; rejected. - Hyperdrive query caching must stay disabled: reads are cached by query text and parameters, and Cloudflare documents neither the role nor transaction-local settings as part of the key (nor whether reads inside a transaction are exempt). `pnpm setup --hyperdrive-id` disables it, `pnpm doctor --connect` reports it, and the production deploy refuses to publish when `packages/project-setup-cli/src/hyperdrive.mjs check` finds it on. - Webhook endpoints are per User, not per Organization: a User's endpoint receives the signals of every Organization they belong to (never row contents; removed Members stop receiving). Scoping endpoints to an Organization is a product change left open. - `pnpm --filter @template-cloudflare/db test:rls-matrix` runs migrations (with `db:verify-rls`), the catalog, cross-role, restore and isolation suites and the tenant-path regressions on PostgreSQL 16 and 18 for each profile (plus a no-`CREATEROLE` owner, a real `pg_dump`/`pg_restore` into a plain-owner cluster and a PostgreSQL 15 refusal); CI runs it as its own job. # Procedures run one Effect program at their edge The repository's architectural decision, rationale and consequences. # Procedures run one Effect program at their edge **Status:** accepted · **Date:** 2026-10-17 · **Amends:** [ADR-0005](0005-effect-boundary.md) **Enforcement:** the lint override for migrated features refuses `@orpc/*` in `*.service.ts`; the exhaustive `switch` in each `.errors.ts` fails `tsc` when a tag is unmapped; characterization tests pin the client-visible code and message of every pilot failure; `apps/web/scripts/assert-client-bundle-clean.mjs` keeps Effect out of client assets. [ADR-0005](0005-effect-boundary.md) kept Effect at the server edges (jobs and outbound transports) and said oRPC handlers do not become Effect. Two problems remained in the request path. Service modules threw raw `new ORPCError(...)` (twelve of them in the document submission service alone), so the domain imported the transport and the set of failures an operation could produce was not a type. And services that do external I/O, such as signing and checking storage objects for Document Text, had no shared way to add a timeout or an idempotent retry. ADR-0005 named the trigger for going further: "several handlers composing I/O with retry and timeout". Documents now meets it, and uploads and billing checkout follow the same shape. ## Decision [#decision] **A migrated procedure runs one Effect program, once, at its edge.** The handler stays an ordinary oRPC Promise handler. Its body is a single call: ```ts handler: ({ context, input, signal }) => runProcedure( documentSource(context.db, context.organizationId!, input.fileId), documentRpcError, { signal, requestId: context.requestId, }, ); ``` `runProcedure` (`packages/api/src/platform/rpc/procedure-edge.ts`) is the only `run*` on that path. It is built on `Effect.runPromiseExit`, like `runEdge`. A success returns the value. A typed failure is converted once by the feature's mapper into an `ORPCError` with an `APP_ERRORS` code. A defect is rethrown, so the existing server error handler logs it and answers 500. A request whose `signal` is already aborted rejects with its reason before the program starts; once the program runs, the client's abort is not wired into it, so it finishes and its result or error is returned (see below). Callers outside oRPC that reuse a service (MCP, the agent tool catalog) go through the same function, so they receive the same `ORPCError` they receive today. **Domain failures are `Schema.TaggedError`s in `.errors.ts`.** Services never import `@orpc/*`. Each feature lists its failures once (`TODO_ERRORS`, `DOCUMENT_ERRORS`); the union is derived from that list and the type guard used by `transaction` reads it. Each feature exports one mapper: a `switch` on `_tag` that ends in `satisfies never`, so an unmapped failure is a compile error, and a failure missing from the list cannot be mapped either. A pilot keeps the code and message the client sees today. No error code, i18n key or client resolver changes, and `packages/errors` stays the client-safe Zod registry. **Services are `Effect.fn` programs that receive their dependencies as arguments.** The request database (`context.db`, already `app_tenant`, [ADR-0009](0009-row-level-security.md)) and the Organization ID are plain parameters, as they were for the Promise services. There is no `Context.Service`, no `Layer` and no `ManagedRuntime`, per request or at module scope. Database work goes through two helpers in `packages/api/src/platform/effect/db.ts`: - `query` wraps `drained` and turns a SQL failure into a defect, which is today's 500. - `transaction` runs `db.transaction` inside `drained`. Inside the callback, which is still a Promise, the body throws tagged errors (they are `Error`s). The helper returns the ones the feature recognises as typed failures and turns everything else into a defect. An interruption from inside the program (a timeout, a deadline) therefore waits for a running transaction to settle instead of abandoning it. **A client that stops waiting cannot undo a commit.** Audited procedures run inside the transaction `withAudit` opens around the handler, so a handler that rejected after the client left would roll back the write and its audit together; that is why `runProcedure` does not interrupt a started program on the client's abort. The response simply goes unread, as before Effect. `todo.abort.test.ts` and `documents.abort.test.ts` block a write on a lock, abort the client mid-statement and check that the write (and its audit) committed. Transaction boundaries and lock order do not change. **External I/O inside a service** uses the stable modules ADR-0005 already allows: a timeout, and `idempotentRead` only for idempotent reads. A storage `PUT`, a provider `POST` or an email send is never retried in-round. **What stays as it is.** - Zod contracts, oRPC routing and middlewares, Better Auth, the Drizzle schema and queries (wrapped, not rewritten), `packages/errors`, web and mobile. - `@orpc/experimental-effect` stays out. - Effect Schema is not used for public contracts. - The cron tick remains one program with one `runEdge`. - Only `@stability stable` APIs are used. **Scope.** `todo` and `documents` are the pilots. Other features keep their Promise services until someone changes them, then follow the `domain-feature` skill. **Logging and correlation.** `runProcedure` annotates the program with the request ID (`Effect.annotateLogs`). A typed failure mapped to a 4xx is not logged as an error. A defect is logged once by the server error handler, with the request ID. Services do not log their own failures. ## Measurement and abort criterion [#measurement-and-abort-criterion] Measured on 2026-10-17 on one shared machine, `main` (`cd1dbb7`) against the final branch, which also contains the request ID, the probe flag through the env schema, `requireOrgRole` and the 400 fix. Size and startup use the methods of `docs/measurements/2026-10-12-effect-edges.md`; latency runs `createRouterClient(appRouter)` in process against a disposable local PostgreSQL (after the independent review: 18 paired series alternating the two worktrees, 100 warm-up and 1,000 measured calls each, plus an 18-series A/A run of `main` against itself; the verdict is the true median of the paired deltas, computed by `scripts/measure-procedure-latency.mjs`). The numbers, commands, the earlier pilot round and the raw results are in [the measurement report](../measurements/2026-10-17-backend-structure.md). | Measure | Budget | `main` | Branch | Result | | ---------------------------------------------------------- | ------------------- | ----------: | ----------: | -------------------------------------------------------------------------------------------------------- | | Server modules, gzip sum (`measure-worker-build --runs 3`) | +≤ 10 KiB | 5,275,576 B | 5,347,497 B | +71,920 B: about 7,017 B of code (+6.9 KiB) and 64,903 B of documentation pages compiled into the Worker | | Effect modules in the graph, server / client | client 0 | 218 / 0 | 218 / 0 | no Effect in the client; client bytes +20 B | | `wrangler check startup`, median `Active` of 7 alternated | within noise | 52.2 ms | 48.8 ms | within noise (both near 50 ms under heavy load) | | CPU attributed to the Effect chunk at startup | +≤ 1 ms | 0 ms | 0 ms | not sampled in any of 28 runs | | `todo.list` p50, median of 18 paired deltas | +≤ 5 % or +≤ 0.1 ms | 0.641 ms | — | +0.013 ms (A/A −0.031 ms) | | `todo.byId` p50 | same | 0.226 ms | — | +0.014 ms (A/A −0.006 ms) | | `todo.byId` p50, `NOT_FOUND` | same | 0.219 ms | — | +0.040 ms, +18 %, IQR +0.037…+0.052 (A/A −0.013 ms) | | `documents.list` p50 | same | 0.270 ms | — | −0.001 ms (A/A −0.004 ms) | The chunk that contains Effect grew by 1,250 B (gzip -9). With a reference p50 between 0.2 and 0.7 ms, the effective latency limit is 0.1 ms. **Correction and caveats.** An earlier version of this record gave, as the "median delta of 4 rounds", the lower middle value computed by hand; the true medians of those rounds were `todo.list` +0.103 ms (over the limit by 3 µs, inside the ±0.2 ms noise of those rounds), `todo.byId` −0.018, `NOT_FOUND` +0.017 and `documents.list` +0.020 ms. The table above replaces them. The machine was shared (1-minute load 70 to 221 during the review runs, recorded per sample). The happy paths sit within the A/A spread; the error path costs a real ~40 µs (its whole interquartile range is positive): building the tagged error, the `Exit` and the mapped `ORPCError` instead of `errors.NOT_FOUND()` directly. That is over 5 % but under the 0.1 ms absolute limit. The Effect chunk is byte-identical since the pilots, so startup was not re-measured. These are local numbers, not Cloudflare cold starts or production latency. **Abort criterion.** If any budget had been exceeded, the pilots would have kept their tagged errors and the single mapper, but their services would have returned Promises again (no `Effect.fn`, no `runProcedure` in the request path), and this record would have been amended with the numbers and that outcome. **Result:** the latency, client and startup budgets are met by the code, and the server code grows by 6.9 KiB of the 10 KiB budget. The Worker as a whole grows by 70.2 KiB gzip because the site's documentation pages, ADRs and changelog are compiled into it; that growth is content, not Effect, and the abort criterion (which only concerns the pilots) would not remove it. Whether documentation counts against the Worker budget is the owner's decision; until then both pilots stay on Effect. ## Why not the alternatives [#why-not-the-alternatives] - **Keep raw `ORPCError` in services.** The transport leaks into the domain, MCP and the catalog depend on oRPC types, and nothing proves every failure is mapped. - **Tagged errors thrown from Promise services, with no Effect program.** It keeps the single mapper but loses the typed failure channel, interruption that waits for a transaction, and composable timeouts. It is the fallback if the measurement fails. - **A per-request `Layer` or `Context.Service` for the database and tenant.** It costs a scope per call for two arguments. It also invites the module-scope runtime that ADR-0005 forbids ("Cannot perform I/O on behalf of a different request"). - **`@orpc/experimental-effect`.** It is still beta on a beta. ## Consequences [#consequences] - During the migration the backend has two service styles. The `domain-feature` skill and the backend conventions page teach the new one; a Promise service is not wrong, only unmigrated. - Procedure spans are named (`Effect.fn("documents.source")`) but not exported. Passing `ctx.tracing` from the Worker `fetch` into the oRPC context is a later change, outside this record. - A new failure is a new tagged error plus one `case`. A new client-visible code still goes through `packages/errors` and every locale, as before. - The documents pilot preserved behavior: the storage `HEAD` that confirms text receives the program's interruption signal (not the client's), but no new timeout or retry was added. Adding them is a separate, measured change. - Going further (Effect-native routing, services or Layers) still needs its own ADR. # Backend features have a fixed file shape The repository's architectural decision, rationale and consequences. # Backend features have a fixed file shape **Status:** accepted · **Date:** 2026-10-17 **Enforcement:** in `pnpm test`, `scripts/leaf-modules.test.mjs` (LEAF imports), `scripts/feature-boundaries.test.mjs` (declared cross-feature edges), `scripts/import-cycles.test.mjs` (no cycles in `packages/api/src` and `packages/db/src`) and `packages/api/src/exports.test.ts` (export targets exist, mobile aliases resolve to the canonical file, the web app does not use them); in `pnpm lint`, the `no-restricted-imports` overrides of `.oxlintrc.json` for the import directions. `packages/api` grew by accretion. Routers lived in `src/routers/`, domain code in one folder per topic, and shared helpers in `src/lib/`. The same kind of file had different names in different places (`uploads/router.ts` next to `routers/todo.ts`), and the main example feature kept its queries inside a 635-line router. A reader could not tell from a file name what the file was allowed to import, and nothing stopped a domain module from importing a router or a job. ## Decision [#decision] **One folder per feature, one fixed name per role.** Every feature lives in `packages/api/src/features//`. Files are named `..ts`: | Role | File | Imports | | -------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | Contract | `.contract.ts`, a **LEAF MODULE** | `zod`, other leaves, `db/schema/*.enums`, any `import type` | | Procedures | `.procedures.ts` | its contract, service and errors, `platform/rpc/*` | | Service | `.service.ts` | `db` as an argument, Drizzle, Effect, leaves, other features' services; never `@orpc/*` | | Errors | `.errors.ts` | `Schema.TaggedError`, and `ORPCError` for the single mapper ([ADR-0010](0010-procedures-run-one-effect-program.md)) | | Rules, enums, policy | `.rules.ts`, `.enums.ts`, `.policy.ts` | leaves only; no I/O | | Jobs | `.jobs.ts` | imported only by `platform/jobs/run.ts` | | Raw HTTP edge | `.http.ts` | imported only by `apps/web`, through `exports` | | Internals | `..ts` | private to the feature unless declared | A feature grows by prefix in the same folder (`billing.stripe.ts`, `billing.reconcile.ts`), never by sub-folder. Cross-cutting code lives in `packages/api/src/platform/` (`rpc`, `effect`, `jobs`, `http`, `data-table`). `src/client.ts`, `src/rpc-scope.ts` and `src/testing/` stay where they are. **No barrel.** The public surface of the package is its enumerated `exports` map, as [ADR-0002](0002-no-barrels-on-server-capable-modules.md) requires. Inside the package, files import each other directly, limited by direction rules: - only the app router and the agent tool catalog import `*.procedures`; - only the job runner imports `*.jobs`; - `platform/**` does not import features, except the app router, the job runner and two declared edges: the procedure builders read `features/admin/admin.roles` for the admin guard, and the request context builds `features/admin/admin.api`; - a feature imports another feature's contract, enums, service, errors, policy or rules, plus internals it declares in the boundary test; - a migrated feature's procedures import no Drizzle and no table module; queries live in its service (the lint rule covers `drizzle-orm`, the schema barrel and the per-table modules; a relational query through `context.db.query.*` needs no import and is left to review); - no import cycles. This check runs as a test, not as a lint rule: oxlint's `import` plugin, which `no-cycle` needs, made the whole lint about 3.7 times slower. **No repository layer.** The service uses Drizzle through the `db` it receives, which is `context.db` running as `app_tenant`. Isolation is guaranteed by row-level security ([ADR-0009](0009-row-level-security.md)), not by a layer of code. A repository would forward calls to the same Drizzle handle, and our tests run against real PostgreSQL rather than mocked data access. The `organizationId` filters stay in the service as defence in depth. **The contract is a leaf, because clients import it.** Web and mobile import contracts as `@template-cloudflare/api//contract`. The four specifiers the mobile app already uses, `@template-cloudflare/api/routers/{todo,integrations,billing,notifications}.schema`, stay in `exports` as aliases of the canonical files, so the native app keeps building without a change. The web app imports only canonical specifiers; a test, not a lint rule, enforces that, because an oxlint override replaces a rule's options instead of merging them and would have had to repeat the whole client boundary. ## Why not the alternatives [#why-not-the-alternatives] - **A `modules//index.ts` barrel as the only import surface.** That is how server code reached the browser before ADR-0002. - **A `.repo.ts` that alone touches the database.** Under RLS it adds a forwarding layer and a second place for every query, without adding isolation. - **Layer-first folders (`routers/`, `services/`).** One feature is spread across the tree and cannot be read or deleted as a unit. - **Sub-folders per feature.** They make paths deeper without making boundaries clearer; the role suffix already says what a file is. ## Consequences [#consequences] - File paths changed in one move. ADRs, docs and skills were updated editorially. Historical measurement reports and plans keep the paths of the revision they describe. - Path-anchored tooling (`optional-features.json`, the `remove-*` scripts, `packages/db/src/system-call-sites.txt`, `.oxlintrc.json` overrides) follows the new paths, and is verified by the optional-removal runs and the system call site inventory. - New features follow the shape through the `domain-feature` skill. Unmigrated features keep topic files and Promise services; when someone migrates one, it gets `service` and `errors`, and its files join the two lint overrides that refuse `@orpc/*` in services and queries in procedures. Those overrides list the migrated features by path, so the transition list only shrinks. - Procedure tests are named `.procedures.test.ts`; client-visible errors of the pilots are pinned by characterization tests in `packages/api/src/testing/characterization/`. - The mobile aliases stay until a mobile change moves the app to the canonical specifiers. # Connect your agent Connect an external agent through MCP OAuth and enable the optional assistant. # Connect your agent This app is the MCP server at `/api/mcp`. External clients and the optional in-app assistant use one tool catalog. Tools read real data authorized for the signed-in User; the public demo posts and profiles are separate static examples. The assistant uses the Session's Active Organization. An MCP access token has no Active Organization, so MCP uses the User's personal Organization and checks current membership on every call. Joining another Organization does not grant the MCP connection access to its data. ## Endpoints [#endpoints] The template's production origin is `https://template.its-live.dev`; replace it with your deployment's origin. Local web development uses `http://localhost:3005`. | What | Path | | --------------------------------------------- | ----------------------------------------- | | MCP resource | `/api/mcp` | | JWKS | `/api/auth/jwks` | | OAuth protected-resource metadata, RFC 9728 | `/.well-known/oauth-protected-resource` | | OAuth authorization-server metadata, RFC 8414 | `/.well-known/oauth-authorization-server` | | Sign-in and OAuth consent | `/login`, `/consent` | | Pending tool approvals | `/integrations` | | In-app assistant, when enabled | `/assistant` | | Session-authenticated API reference | `/api/rpc/api-reference` | | Session-authenticated OpenAPI document | `/api/rpc/api-reference/spec.json` | An unauthenticated `POST /api/mcp` returns `401` with a Bearer challenge. MCP Inspector and local CLI clients can use the localhost URL; hosted clients need a reachable HTTPS deployment. ## OAuth and clients [#oauth-and-clients] Better Auth handles OAuth 2.1 with PKCE S256 and dynamic client registration. Public clients register with `token_endpoint_auth_method: none`. Client ID Metadata Documents are not advertised; dynamic registration is the supported path. | Scope | Access | | ---------------- | ------------------------------------------------------------------ | | `openid` | Identity for the grant | | `profile` | Standard OAuth profile scope; tool calls still require `data:read` | | `offline_access` | Refresh tokens for clients that need persistent connections | | `data:read` | Read tools; required by the MCP route | | `data:write` | Request approval for write tools; separately consented | Read-only grants never see write tools in `tools/list`. Calling a write tool without `data:write` receives `403` with `insufficient_scope`. Write scope permits requesting a change; each exact change still needs the User's approval in the app. Revoking a connected agent invalidates subsequent MCP access even before its JWT expires. MCP verifies access-token signatures against the jwt plugin's JWKS, cached per Worker isolate for 60 seconds (`MCP_JWKS_TTL_MS` in `apps/web/src/routes/api/mcp-require-auth.ts`). A token with an unknown `kid`, or whose signature fails, makes the isolate read the JWKS again at once, at most once every 5 seconds. A new signing key is therefore accepted immediately. Removing a leaked key has a revocation window: a warm isolate can accept tokens signed with it for up to 60 seconds after the key leaves the JWKS. Revoking the agent (above) is not bounded by that window. For Claude Code: ```sh claude mcp add --transport http template-cloudflare \ https://template.its-live.dev/api/mcp ``` Open `/mcp` in Claude Code and complete the browser sign-in and consent. For a local instance, substitute `http://localhost:3005/api/mcp`. For Claude Desktop/claude.ai or ChatGPT, add the HTTPS MCP URL as a custom connector and complete OAuth. ChatGPT's documented path for this template is Developer mode; include `offline_access` for refresh tokens. The template is not automatically listed in any client's reviewed plugin directory. See [the client connection table and publishing distinction](../README.md#chatgpt-connector-vs-official-plugin). Grok Build can use the same HTTP endpoint: ```sh grok mcp add --transport http agent-plugin \ https://template.its-live.dev/api/mcp ``` The optional Agent Plugins package for compatible hosts is documented in [`plugins/agent-plugin`](../plugins/agent-plugin/README.md). It points at this same OAuth resource. If OAuth succeeds but a client shows no tools, first inspect the grant's scopes and revocation state. JWKS must be loaded in-process through Better Auth; a Worker HTTP self-fetch is not equivalent. A client may also cache discovery from an earlier configuration, in which case reconnect it. The [MCP validation guide](./mcp-e2e-validation.md) covers deployment verification. ## Tools, resources and approval [#tools-resources-and-approval] The 15 tools below come from `packages/api/src/features/agent-tools/agent-tools.catalog.ts`. `tools/list` remains the runtime authority for the client's granted scopes. A read-only grant exposes nine tools. | Tool | Behavior | | -------------------------------------------------------- | --------------------------------------------------------------------------------- | | `todo_list` | List or search paginated Organization Todos; advertises the existing todo widget | | `todo_detail` | Read one authorized Todo | | `files_list` | List Organization file metadata, confirmed file IDs and conversion status | | `whoami` | Read the signed-in User's name and email | | `search` | Search Todo text, file names and converted document chunks | | `fetch` | Read `todo:`, `file:` or `document::` | | `document_read` | Read converted Markdown by `fileId`, with bounded pages and a continuation cursor | | `organization_info` | Read the authorized Organization and member names/roles | | `ask_for_input` | Ask for a choice in the assistant; standalone MCP calls return no answer | | `todo_create` | Create a Todo after approval | | `todo_complete` | Mark a Todo done after approval | | `import_start` | Start an import from a confirmed Organization JSON file after approval | | `create_todo_draft`, `draft_task_plan`, `propose_review` | Create an inert proposal after approval; applying it remains a separate action | An MCP write returns `pending_user_approval`, an approval ID, expiry and the Integrations URL. Open Integrations in the same personal Organization, review the exact arguments, then approve or deny. Approval requests expire after 15 minutes; at most 20 unexpired pending requests are allowed per User. Approval cannot be transferred to another User, Organization, tool or argument set. Execution records its result atomically with the domain change; replaying an executed approval returns the stored result. Imports also require their existing UUID `requestKey`, confirmed upload and source-version checks. An approved import starts an Import Run; the existing scheduled job performs the import. It does not make the HTTP request a background executor. `template://organization/todos` exposes the first page of authorized Todos as an MCP resource. The `plan-my-week` prompt asks the agent to inspect Todos and seek approval before changes. `todo_list` keeps `ui://template-cloudflare/todos` for MCP Apps and `ui://template-cloudflare/todos.html` for the OpenAI bridge. Hosts without iframe support keep the JSON result. See [MCP widgets](./mcp-widgets.md). ## Read a document or plan from it [#read-a-document-or-plan-from-it] Upload the source in Files and wait for its conversion status. Conversion runs in a browser Web Worker. `files_list` returns `fileId` and a `document` object with `status`, `failureReason`, `awaitingBrowser`, `chunkCount` and `markdownBytes`. When `awaitingBrowser` is true, open Files to complete conversion before requesting text. The document object is null when converted text is unavailable, including unsupported file types. Older files without an upload record also have a null `fileId`. Call `document_read` with `{ "fileId": "" }`. The result contains conversion status, Markdown chunks and `nextCursor`. The default is two chunks per page; `limit` accepts one through four, for at most 32 KiB of Markdown. Pass `nextCursor` as the next call's `cursor` until it is null, or stop when you have read the relevant sections. Search results use `document::` IDs so `fetch` can start at the matching section. `fetch` also accepts `cursor` and `limit`. No tool returns the original file bytes or automatically loads all converted text. The resource template `template://organization/documents/{fileId}` returns the same first JSON page. Continue through `document_read`. Failed conversions return their permanent reason, such as `needsOcr`, `encrypted` or `too_large`; the stored original remains available in Files. Use the `plan-from-document` MCP prompt with a `fileId` and optional `goal`, or ask the in-app assistant to plan from the file. The agent reads relevant pages, then calls `draft_task_plan`. Approving that tool creates an inert proposal. Review and Apply the proposal before any live Todos are created. Treat source content as evidence, not instructions to change permissions or bypass approval. The in-app assistant sends the pages it reads to your configured model provider and retains the tool results in its Thread. A connected MCP client receives pages through its authorized grant. Document conversion itself uses no hosted model API. See [ADR-0008](./adr/0008-documents-and-large-uploads.md) for conversion caps and storage. ## Enable the in-app assistant [#enable-the-in-app-assistant] MCP works without an AI provider. The in-app assistant is off until both provider configuration and the `ai.assistant` flag are enabled. 1. Apply the journaled migration with `pnpm --filter @template-cloudflare/db db:apply-pending` against the intended database. For tests, use only `node scripts/test-local-postgres.mjs`. 2. Set `AI_PROVIDER` in the server environment and add its matching API key. Local development reads `apps/web/.env`; deployed keys belong in Worker secrets. 3. Optionally set `AI_MODEL` to override the default below. Model availability depends on the provider account. 4. As an administrator, enable `ai.assistant` for web in `/admin/flags`. The sidebar entry and `/assistant` become available after configuration and the flag are both present. | `AI_PROVIDER` | Worker secret | Default `AI_MODEL` | | ------------- | ------------------- | ------------------- | | `anthropic` | `ANTHROPIC_API_KEY` | `claude-sonnet-5-5` | | `openai` | `OPENAI_API_KEY` | `gpt-6-astra` | | `gemini` | `GEMINI_API_KEY` | `gemini-4-argon` | For example, add the Anthropic secret interactively with `pnpm --filter web exec wrangler secret put ANTHROPIC_API_KEY`. Set `AI_PROVIDER=anthropic` in the Worker's environment configuration. Leave `AI_PROVIDER` absent to keep model access off; setting only the flag does not enable it. API keys are never returned to the browser. | Limit | Default | Allowed range | | -------------------------------------- | ------: | ---------------: | | `AI_MAX_STEPS` | 5 | 1 to 10 | | `AI_MAX_OUTPUT_TOKENS`, per model call | 2,048 | 128 to 8,192 | | `AI_TIMEOUT_MS`, abort deadline | 60,000 | 1,000 to 120,000 | | `AI_REQUESTS_PER_MINUTE`, per User | 10 | 1 to 60 | Threads are private to their User and Organization. Messages and tool results stay in Postgres until the Thread, User or Organization is deleted. Deleting history does not undo approved changes. Stop and disconnect end the current generation; reopening loads saved snapshots and pending questions or approvals. There is no background run or resumable stream after disconnection. Provider retention is separate. The fake adapter is test-only: it requires a build made with `--mode ai-test` and runtime `AI_TEST_PROVIDER=true`. A normal production build cannot select it. Test runs still configure a provider with a dummy local key and enable the flag, then exercise the same authorization, SSE and persistence paths against disposable Postgres. Never deploy an `ai-test` build. In that test build, `Read document ` exercises `document_read`. `Plan from document ` reads a page, requests approval for `draft_task_plan`, and returns the proposal for the separate Apply step. ## Public documents for agents [#public-documents-for-agents] `/llms.txt` indexes public content and connection endpoints; `/llms-full.txt` includes landing text, legal text, static demo content and this guide. `/index.md`, `/privacy.md` and `/terms.md` use the same content as their HTML pages and support `?lang=en`, `?lang=de` and `?lang=fr`. The sitemap contains public pages and documents. Robots excludes authenticated routes, and public page metadata links to Markdown alternates. None of these documents contains private messages or Organization data. OAuth discovery uses the existing well-known endpoints; no experimental MCP server card is advertised. Architectural decisions and retention limits are recorded in [ADR-0007](./adr/0007-ai-assistant-and-tool-catalog.md). # Setup, storage and localization The early template gains repeatable configuration, object storage and language support. The setup CLI and R2 integration establish the path from a clone to configured infrastructure. Early localization and cookie work provide the multilingual application foundation. Read [setup](/docs/development/setup), [files](/docs/features/files) and [internationalization](/docs/features/i18n) for the current flows. Sources: [R2 and setup](https://github.com/CarlosZiegler/template-cloudflare/commit/d9ceeba), [localization](https://github.com/CarlosZiegler/template-cloudflare/commit/3804c9d). # Administration and data workflows Admin operations and server-backed tables extend the starter application. Administrative capabilities and architecture cleanup expand the authenticated application. Earlier July work introduces server-backed tables and database forms, establishing the patterns used by later domain features. See [admin](/docs/features/admin), [oRPC conventions](/docs/features/api) and [database migrations](/docs/features/database). Sources: [admin](https://github.com/CarlosZiegler/template-cloudflare/commit/159b490), [server tables](https://github.com/CarlosZiegler/template-cloudflare/commit/7b8a409), [database forms](https://github.com/CarlosZiegler/template-cloudflare/commit/a2443ce). # Native interface foundations Expo screens adopt the native UI direction. The mobile application adds native interface work for its core screens. Subsequent identity and OTA changes build on this foundation while keeping SDK and renderer compatibility explicit. The [mobile guide](/docs/features/mobile) describes the current routes, startup registration and update policy. [Source: native UI](https://github.com/CarlosZiegler/template-cloudflare/commit/a696762) # An enforced client/server boundary Shared constants stop pulling server capabilities into browser bundles. A server-capable module can no longer act as a convenience barrel. Clients consume import-free enum leaves, safe schemas and type-only API contracts. Oxlint rules, enumerated package exports and a built-client marker scan enforce the boundary. Read [ADR-0002](/docs/adr/0002-no-barrels-on-server-capable-modules) and the [extension guide](/docs/architecture/client-server-boundary). [Source: boundary enforcement](https://github.com/CarlosZiegler/template-cloudflare/commit/43dd89d) # Cloudflare email and shared UI Transactional delivery and the shared interface foundation expand. Cloudflare email becomes a supported transactional transport. Shared shadcn component work establishes the visual foundation used by the application, while auth session seams become more explicit. Read [email delivery](/docs/features/email) and the [UI package reference](/docs/packages/ui) for the current implementation. Later UI cleanup removed the former ReUI dependency; historical release notes do not imply it remains installed. Sources: [email](https://github.com/CarlosZiegler/template-cloudflare/commit/a4be02b), [UI foundation](https://github.com/CarlosZiegler/template-cloudflare/commit/093f17c). # Agent connections and native account security OAuth-backed MCP connections and mobile security flows expand the template. MCP agent integrations add the connection path used by external clients, including ChatGPT registration support. Mobile gains two-factor and passkey flows. Setup and deployment skills document the operational steps for a configured clone. See [connecting your agent](/docs/connect-your-agent), [authentication](/docs/features/auth) and [local setup](/docs/development/setup). Sources: [agent integration](https://github.com/CarlosZiegler/template-cloudflare/commit/03bee51), [ChatGPT registration](https://github.com/CarlosZiegler/template-cloudflare/commit/d0dcc31), [native security](https://github.com/CarlosZiegler/template-cloudflare/commit/3c6437e). # Typed domain operations and a real dashboard Dashboard data, Todo state and administrative writes become more explicit. The dashboard uses application data. Todo statuses and filters, feature flag payloads, audit/webhook records and OAuth payloads gain typed boundaries. Admin writes require their audited path, and one request scope owns server auth/database work. - [Admin](/docs/features/admin) - [Feature flags](/docs/features/feature-flags) - [Request lifecycle](/docs/architecture/request-lifecycle) Sources: [dashboard](https://github.com/CarlosZiegler/template-cloudflare/commit/cdbf4d7), [Todo contracts](https://github.com/CarlosZiegler/template-cloudflare/commit/140fe06), [audited admin writes](https://github.com/CarlosZiegler/template-cloudflare/commit/d282d15), [request scope](https://github.com/CarlosZiegler/template-cloudflare/commit/ef0b0c0). # oRPC and Drizzle upgrades The API and database stack advance with compatibility work across their consumers. The dependency integration upgrades oRPC to its v2 line and Drizzle to the new relation API, alongside UI utility updates. These coupled changes are verified as one integration rather than independently bumping adapter peers. The [API guide](/docs/features/api) describes current procedures and contracts; the [database guide](/docs/features/database) covers migration discipline. [Source: API/database upgrade](https://github.com/CarlosZiegler/template-cloudflare/commit/6bca3ed) # Platform dependency alignment React, Drizzle, files and test tooling move onto verified compatible versions. The web adopts React 19.3 while native keeps the React version required by its renderer. Drizzle kit and ORM align on the same release-candidate build. Files SDK 2.6, MCP tooling and Vitest 5 receive dedicated upgrades. Files authorization failures now preserve the intended unauthorized response. A dedicated Hyperdrive configuration and security fixes accompany the dependency work. Read the [library policy](/docs/libraries), [client/server boundary](/docs/architecture/client-server-boundary) and [files guide](/docs/features/files). Sources: [React](https://github.com/CarlosZiegler/template-cloudflare/commit/16f5192), [Drizzle](https://github.com/CarlosZiegler/template-cloudflare/commit/261d5a1), [files](https://github.com/CarlosZiegler/template-cloudflare/commit/62735bd), [unauthorized response](https://github.com/CarlosZiegler/template-cloudflare/commit/9ae5a0d). # Authentication foundations and performance Auth state has clearer ownership on web and mobile, with production build checks. The auth foundation consolidates Session outcomes and account-security behavior. Web and native hooks take responsibility for orchestration, making screens easier to follow and reducing duplicated lifecycle logic. Performance work improves the web and mobile paths, adopts React Compiler where supported and replaces avoidable lazy module loading. A Worker server-function manifest regression gains a production build guard. Database suites are serialized where they share state. - [Authentication](/docs/features/auth) - [Request lifecycle](/docs/architecture/request-lifecycle) - [Conventions](/docs/development/conventions) Sources: [auth foundation](https://github.com/CarlosZiegler/template-cloudflare/commit/5ab1b8e), [web hooks](https://github.com/CarlosZiegler/template-cloudflare/commit/962985b), [native hooks](https://github.com/CarlosZiegler/template-cloudflare/commit/e00274b), [Worker guard](https://github.com/CarlosZiegler/template-cloudflare/commit/2ae12d9). # Runtime and recovery hardening Template fixes tighten persisted work and production-runtime behavior. The template-fixes integration consolidates reliability changes and the groundwork for bounded server execution. Follow-up work separates the Effect transport boundary and records its operational constraints rather than replacing the public Zod/oRPC contracts. Read [Effect at the edges](/docs/architecture/effect), [file recovery](/docs/features/files) and the [testing workflow](/docs/development/testing) before changing these paths. [Source: template fixes](https://github.com/CarlosZiegler/template-cloudflare/commit/699dbba) # Payments, native screens and a new landing page Optional billing providers, a refreshed native experience and clearer product presentation land together. Web billing supports Stripe and Polar; mobile purchases integrate RevenueCat. Provider selection stays explicit and visibility flags default off. Server verification owns Paid Access. The public landing page gains product showcases and an architecture illustration. Mobile screens receive the Kada-style refresh while retaining native navigation and session ownership. Effect now coordinates the outbound transports and the scheduled tick under bounded deadlines. - [Payments](/docs/features/payments) - [Mobile app](/docs/features/mobile) - [Scheduled jobs](/docs/features/scheduled-jobs) Sources: [payments](https://github.com/CarlosZiegler/template-cloudflare/commit/17e6eb8), [native UI](https://github.com/CarlosZiegler/template-cloudflare/commit/d3b5182), [landing](https://github.com/CarlosZiegler/template-cloudflare/commit/237047b), [Effect edges](https://github.com/CarlosZiegler/template-cloudflare/commit/b8fc8af). # TanStack Start 1.0, OG images and durable imports The web runtime advances, social images render in the Worker, and imports gain bounded queue execution. The template moves to TanStack Start 1.0 (`@tanstack/react-start` 1.169.1). Takumi renders dynamic OG images in the Worker, with a rendering boundary that avoids React Compiler’s dispatcher requirement. Todo imports use stable Effect Queue, Stream and Schedule APIs with Drizzle leases. The workerd persistence spike is recorded in ADR-0006; its failed lost-acknowledgement shutdown gate is why the experimental PersistedQueue is not the production backend. - [OG images](/docs/features/og-images) - [Todo imports](/docs/features/todo-imports) and [Effect boundary](/docs/architecture/effect) - [Architecture](/docs/architecture) Sources: [Start upgrade](https://github.com/CarlosZiegler/template-cloudflare/commit/d58ab49), [OG images](https://github.com/CarlosZiegler/template-cloudflare/commit/858a7b3), [import queue](https://github.com/CarlosZiegler/template-cloudflare/commit/0192d47). # Trace and code block primitives Shared shadcn components support readable execution traces and highlighted code. The UI package adds trace and code-block components for developer-facing interfaces. Shared styling, copy controls and highlighting provide a consistent foundation for code and execution details. See the [UI package](/docs/packages/ui). Documentation uses the same GitHub light/dark highlighting themes, compiled during the build. [Source: UI primitives](https://github.com/CarlosZiegler/template-cloudflare/commit/9bf4f25) # AI assistant and one tool catalog The assistant, MCP and agent-readable pages now share the application’s authorized operations. The web assistant adds persisted Threads, streamed responses and explicit approval for mutating tools. Tool execution uses one server catalog shared with MCP, preserving current Session and Organization authorization. Provider configuration and the `ai.assistant` flag jointly control availability. Agent discovery now exposes public instructions and Markdown pages alongside OAuth connection metadata. The supported transport keeps TanStack AI’s Promise/SSE lifecycle separate from the Effect job boundary. - [AI assistant and approvals](/docs/features/assistant) - [MCP server](/docs/features/mcp) and [connecting an agent](/docs/connect-your-agent) - [Agent-readable pages](/docs/features/agent-pages) [Source: assistant integration](https://github.com/CarlosZiegler/template-cloudflare/commit/63e36e6) # Dependency round, i18next fix and ReUI controls Eligible packages advance under the two-day quarantine, a reachable i18next ReDoS is patched and six free ReUI controls join the UI package. i18next 26.4.4 fixes [GHSA-5jr9-983r-c8x4](https://github.com/advisories/GHSA-5jr9-983r-c8x4), a ReDoS reachable through the intl package. The expired TanStack quarantine exceptions were removed, so the workspace keeps its 2880-minute `minimumReleaseAge` with no exclusions. Vulnerable transitive ranges such as `hono`, `qs`, `undici` and `@modelcontextprotocol/sdk` were re-resolved within their declared ranges. Direct pins advance for zod, vitest, TanStack Query and Table, evlog, lucide-react, the AWS S3 clients and Takumi 2.15.1. files-sdk 2.6.2 drops the error-identity workaround; the rebased patch keeps upload authorization, Organization prefix, declared size/type and conditional-create checks. Wrangler, the Vite plugin, oxlint and turbo stayed on their previous versions until their releases qualify. The UI package adds free ReUI `time-picker`, `color-picker`, `signature-pad`, `file-type`, `emoji-picker` and `cascader` components with explicit exports. The cascader brings `@tanstack/react-virtual`. EmojiPicker loads `emojibase-data` from jsDelivr unless local `data` is passed. - [Libraries and version policy](/docs/libraries) - [UI package](/docs/packages/ui) Sources: [security fix](https://github.com/CarlosZiegler/template-cloudflare/commit/bea0767), [package bumps](https://github.com/CarlosZiegler/template-cloudflare/commit/40e1390), [transitive ranges](https://github.com/CarlosZiegler/template-cloudflare/commit/9c59445), [ReUI controls](https://github.com/CarlosZiegler/template-cloudflare/commit/8fc6177). # Developer documentation and changelog The Worker now serves searchable developer guides, imported ADRs and a curated changelog at /docs. `/docs` renders guides for setup, architecture, features and every workspace package, plus the original ADRs and the agent connection guide. Fumadocs runs headless: `fumadocs-core` and `fumadocs-mdx` compile MDX at build time, articles render on the server and search uses the existing command palette. Every page also has a Markdown version and is listed in `llms.txt` and the sitemap. The changelog backfills curated releases from June to October 2026, with individual pages and an RSS feed. `scripts/changelog-draft.mjs` prints candidate commits since the latest entry for human curation. - [Writing documentation](/docs/development/docs) - [Libraries and version policy](/docs/libraries) - [Architectural decisions](/docs/adr) Sources: [developer docs](https://github.com/CarlosZiegler/template-cloudflare/commit/0247631), [changelog](https://github.com/CarlosZiegler/template-cloudflare/commit/a165f29). # Documents and large uploads Files upload directly to R2, including multipart uploads up to 5 GiB, and supported documents become searchable text for the assistant and MCP. Uploads now go directly from the browser to R2 through signed PUTs. Files above 16 MiB use resumable multipart uploads with 8 MiB parts. Images can be up to 100 MiB, documents 1 GiB and ZIP archives 5 GiB; cron recovers lost acknowledgements and aborts abandoned sessions. Supported documents (text-layer PDF, Office, OpenDocument, RTF, EPUB, CSV and plain text) are converted in a browser Web Worker with AnyDoc WASM. Cron validates and indexes up to 192 KiB of Markdown per document. A server-side conversion was measured and rejected because it exceeded the 128 MiB isolate limit. The assistant and MCP read the indexed text through `files_list`, `search`, `fetch` and paged `document_read`, plus the `plan-from-document` prompt. - [Files and uploads](/docs/features/files) - [Documents](/docs/features/documents) - [Scheduled jobs](/docs/features/scheduled-jobs) - [ADR-0008](/docs/adr/0008-documents-and-large-uploads) Sources: [direct uploads and conversion](https://github.com/CarlosZiegler/template-cloudflare/commit/81a60af), [document tools](https://github.com/CarlosZiegler/template-cloudflare/commit/20d34f5), [browser workers](https://github.com/CarlosZiegler/template-cloudflare/commit/e3ec8b0), [bounded indexing](https://github.com/CarlosZiegler/template-cloudflare/commit/b973be0). # Landing page redesign The public landing uses a Taste v2 typographic direction with real interface captures and docs links. The landing page organizes around one idea: a single Worker serving TanStack Start, Expo and the MCP tool catalog. Real interface captures replace placeholder imagery, with localized light and dark WebP screenshots of the todo grid, files, security settings and integrations. The interactive request instrument remains, and the header and footer link to the docs and changelog. URL locales now survive hydration, and the home OG image shares the new headline. The authenticated app keeps its product design system; Taste v2 applies to the public landing only. - [Agent-readable pages](/docs/features/agent-pages) - [Open Graph images](/docs/features/og-images) Sources: [redesign](https://github.com/CarlosZiegler/template-cloudflare/commit/2065591), [locale hydration](https://github.com/CarlosZiegler/template-cloudflare/commit/08c6fa5), [refinements](https://github.com/CarlosZiegler/template-cloudflare/commit/e1789a4). # One folder per backend feature, tagged errors and written rules packages/api moves to features and platform folders with fixed file names, two pilot features run one Effect program per procedure, and every rule now names what enforces it. **Breaking for imports of `@template-cloudflare/api`.** Server code moved from `routers/`, `lib/` and topic folders to `packages/api/src/features//..ts`, with shared building blocks in `packages/api/src/platform/`. Public subpaths now follow `.//`, for example `./todo/contract`, `./app-router`, `./uploads/service` and `./assistant/http`. If your fork imports an earlier subpath, use the table in the [api package reference](/docs/packages/api#renamed-subpaths). The four subpaths the mobile app imports (`./routers/{todo,integrations,billing,notifications}.schema`) remain as aliases, so the native app builds unchanged. - **Feature shape.** Every feature has the same role files: a leaf contract, procedures, a service, errors, and optional jobs and HTTP edges. There is no barrel and no repository layer; row-level security already isolates Organizations. The `@template-cloudflare/api/mcp/*` subpaths are server-only in the client boundary. New tests and lint rules hold the shape: leaf modules import only leaves, features import each other through declared edges, there are no import cycles, type imports are explicit, and configuration is read through `packages/env`. [Backend shape](/docs/architecture/backend-shape), [ADR-0011](/docs/adr/0011-backend-feature-shape). - **Tagged domain errors and Effect in procedures.** In `todo` and `documents`, services fail with tagged errors, and one mapper per feature turns them into the same `APP_ERRORS` codes and messages clients saw before. Each procedure runs its service once through `runProcedure`. A client abort is honoured only before the program starts; once it runs, a client that goes away no longer rolls back the write, so the write and its Audit Event commit and the response goes unread. Other features keep their services until they are migrated. [ADR-0010](/docs/adr/0010-procedures-run-one-effect-program), [error handling](/docs/development/backend-conventions#domain-failures-are-tagged-errors-mapped-once). - **Request ID and quieter logs.** Every RPC response carries `x-request-id`, including the 500 when building the request context fails: Cloudflare's `cf-ray` when it is well formed, otherwise a UUID. The MCP context derives its own request ID for the logs of its document tools. The same ID is `context.requestId` on the server and is part of the error log, so a user can quote it. It correlates logs only: outside Cloudflare a client can supply the `cf-ray`, so nothing decides by it. Expected 4xx failures are logged at debug level instead of as errors. Runtime switches (`DB_TENANT_PIPELINE`, `TRUSTED_PROXY_HEADER`, `TRUSTED_PROXIES`, `TENANCY_PROBE`) are declared in the env schema. Their meaning is unchanged, but an invalid value is now reported instead of silently replaced by a default; check these variables in your Worker and Node deployments. - **Input validation answers HTTP 400.** An invalid input now returns HTTP 400 with the same `INPUT_VALIDATION` code, message and field errors as before; it used to come back as HTTP 500, because oRPC's default status map did not know the code. The RPC and OpenAPI handlers use `errorStatusMap` from `@template-cloudflare/errors/server`, and `APP_ERRORS.INPUT_VALIDATION.status` is 400 instead of 422, which also changes the status in the generated OpenAPI document. The assistant's HTTP route maps statuses with the same shared map, so `INPUT_VALIDATION` is 400 there as well. Clients that branch on `code` see no change. [Error handling](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/error-handling.md). - **Org roles, ready when needed.** `orgProcedure` reads the Member's Org role with its membership check, and `requireOrgRole(...)` restricts a procedure to some roles, failing closed. Roles are not hierarchical (list every role that may act), and using it outside `orgProcedure` is a type error. No procedure applies it yet. [Backend conventions](/docs/development/backend-conventions#org-roles-are-checked-only-where-a-product-rule-needs-them). - **Written rules with their checks.** [Backend conventions](/docs/development/backend-conventions) state each rule with its reason, example, anti-pattern and enforcement. The [enforcement matrix](/docs/development/enforcement) shows which rules a check holds and which rely on review. A [review checklist](/docs/development/review-checklist), a pull request template and a root `CODING_STANDARDS.md` index the same rules. - **Runbooks.** [Runbooks](/docs/development/runbooks) cover a database restore or host move, the pipelining kill switch, Hyperdrive caching, the RLS proof on a preview, and the emergency shutdown. The emergency-shutdown workflow does not block a custom domain; the runbook gives the extra step. - **Decisions show their status.** Every ADR starts with its status and acceptance date, and the [decision index](/docs/adr) lists them. The [pagination](/docs/features/api#pagination) and [testing](/docs/development/testing#what-to-test) rules are written down. - **A skill for new features.** The `domain-feature` skill walks from a glossary term to an RLS table, contract, tagged errors, service, procedures, isolation tests and docs. Measurements ([report](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/measurements/2026-10-17-backend-structure.md)): the server code grows by 6.9 KiB gzip, within the 10 KiB budget for the Effect pilots, and the client carries no Effect. The happy-path p50 of the pilot procedures is within the noise of an A/A run; the `todo.byId` error path costs +0.040 ms. Separately, the Worker as a whole grows by 70.2 KiB gzip, of which 63.4 KiB are the documentation pages, ADRs and changelog the site renders on the server: that is content and has nothing to do with Effect, and whether it counts against the Worker budget is still open. Sources: [platform move](https://github.com/CarlosZiegler/template-cloudflare/commit/0aa202e), [feature folders, first of 18 moves](https://github.com/CarlosZiegler/template-cloudflare/commit/3d1913f), [graph tests](https://github.com/CarlosZiegler/template-cloudflare/commit/2226f26), [direction rules](https://github.com/CarlosZiegler/template-cloudflare/commit/05836ac), [runProcedure](https://github.com/CarlosZiegler/template-cloudflare/commit/91a2d11), [todo pilot](https://github.com/CarlosZiegler/template-cloudflare/commit/6c77fb1), [documents pilot](https://github.com/CarlosZiegler/template-cloudflare/commit/49a4de0), [request ID](https://github.com/CarlosZiegler/template-cloudflare/commit/fa1cbbb), [error log levels](https://github.com/CarlosZiegler/template-cloudflare/commit/ec63950), [runtime switches](https://github.com/CarlosZiegler/template-cloudflare/commit/0df1feb), [trusted proxy](https://github.com/CarlosZiegler/template-cloudflare/commit/47a3700), [requireOrgRole](https://github.com/CarlosZiegler/template-cloudflare/commit/3e51803), [400 for input validation](https://github.com/CarlosZiegler/template-cloudflare/commit/146d310), [measurement](https://github.com/CarlosZiegler/template-cloudflare/commit/ec94469).