# Foundation: Monorepo & Infrastructure — Implementation Plan

**Spec:** docs/specs/2026-05-30-foundation-monorepo.md  ·  **Slug:** foundation-monorepo  ·  **Wave:** 0
**Depends on:** —

## Goal
Establish the Turborepo + pnpm monorepo that every other Zync.is spec builds on: three Cloudflare apps (`zync-www` Astro, `zync-app` Vite+React SPA, `zync-api` Hono Worker) and five shared packages (`@zync/ui`, `@zync/db`, `@zync/types`, `@zync/auth`, `@zync/config`). It defines the Cloudflare binding topology (Neon via Hyperdrive, R2, KV, Queues, Workers AI, Durable Objects, Vectorize, RateLimiters, Analytics Engine), the mandatory multi-tenant query-wrapper isolation model, the security baseline (CSP/nonce, security headers, timing-safe equality, Zod validation, audit-in-transaction), and the CI/CD pipeline. This task ships scaffolding and shared primitives only — it owns **no domain tables**; every concrete table belongs to a downstream spec.

## Architecture
The monorepo root holds `turbo.json`, `pnpm-workspace.yaml`, and a root `package.json` orchestrating builds across `apps/*` and `packages/*`. Dependency direction is strict and acyclic: `@zync/types` is the leaf; `@zync/db` imports only `@zync/types`; `@zync/auth` imports `@zync/types`; `@zync/ui` imports `@zync/config`; `@zync/config` is leaf tooling. Apps import packages, never the reverse.

`@zync/db` exposes `createDb(env)` (a Drizzle instance bound to Neon through the Hyperdrive `DB` binding) plus the empty-but-structured `schema/`, `migrations/`, and `queries/` directories that downstream specs populate. The isolation core lives in `packages/db/src/queries`: `tenantQuery(db, tenantId)` (tenant-scoped helpers) and `systemQuery(db)` (admin, no tenant scope). Route handlers MUST go through these factories — enforced by the `no-raw-drizzle-from-routes` ESLint rule.

`zync-api` (Hono) is the single API surface under `zync.is/api/*`, issues JWTs into an `httpOnly` cookie scoped `domain=.zync.is`, and mounts global middleware for security headers (CSP with per-request nonce), CORS (credentialed, origin-pinned), and the binding `Env`. It runs on Cloudflare's Free stateless pool, so authenticated hot paths must remain below the 10 ms CPU ceiling. `zync-app` is a pure CSR SPA with route-based code splitting (every module is a `React.lazy` chunk). `zync-www` is Astro on the Cloudflare adapter serving marketing + auth pages that POST to `zync-api`.

This task declares no upstream tables/exports (it is the root). It **publishes** the names other specs lock to: the `Env` interface, the package names, `createDb`/`tenantQuery`/`systemQuery`, `timingSafeEqual`, the shared enums/branded types, the four ESLint rule ids, and the queue names.

## Tech Stack
- **Apps:** `apps/zync-www` (Astro 4 + `@astrojs/cloudflare`, React islands), `apps/zync-app` (Vite 5 + React 19 + react-router v7 + Zustand + TanStack Query v5, Cloudflare Workers static assets), `apps/zync-api` (Hono on Cloudflare Workers).
- **Packages:** `@zync/ui` (React + Tailwind + Storybook), `@zync/db` (Drizzle ORM + drizzle-kit + `@neondatabase/serverless`), `@zync/types` (DTOs, enums, branded types), `@zync/auth` (`jose` JWT, RBAC pure fns, crypto), `@zync/config` (eslint flat config, tsconfig base, tailwind preset).
- **Tooling:** Turborepo, pnpm 9, TypeScript 5 strict, ESLint 9 flat, Prettier 3, Vitest, Playwright, Drizzle Kit, Wrangler 4.
- **Cloudflare bindings:** Hyperdrive→Neon (`DB`), R2 (`STORAGE`), KV (`KV`), Queues (`QUEUE`), Workers AI (`AI`), Durable Objects (`DO_REALTIME`/`TenantRealtimeDO`), Vectorize (`VECTORIZE`), native RateLimiters (`RATE_LIMITER_AUTH`, `RATE_LIMITER_WEBHOOK`, `RATE_LIMITER_EXPENSE_UPLOAD`, `RATE_LIMITER_LEAD_FORM`), Analytics Engine (`ANALYTICS_ENGINE`). Smart Placement enabled; authenticated stateless requests must remain within the Free-plan 10 ms CPU limit.
- **CI/CD:** GitHub Actions; preview + production Cloudflare environments; `drizzle-kit migrate` against Neon.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 0a — Root scaffold | 1 | `package.json`, `pnpm-workspace.yaml`, `turbo.json`, `.gitignore` | No (root for all) |
| 0b — Shared config | 2 | `packages/config/*` | After 1 |
| 0c — Leaf packages | 3, 4 | `packages/types/*`, `packages/auth/*` | Parallel (both after 2) |
| 0d — DB + UI | 5, 6 | `packages/db/*`, `packages/ui/*` | Parallel (after 3/4 resp. 2) |
| 0e — API Worker | 7, 8, 9 | `apps/zync-api/*` (app, security middleware, wrangler bindings) | Sequential within app (after 3,4,5) |
| 0f — Frontends | 10, 11 | `apps/zync-app/*`, `apps/zync-www/*` | Parallel (after 2,5,6) |
| 0g — Infra decls | 12 | `apps/zync-api/wrangler.toml` queues+cron, `.dev.vars.example` | After 9 |
| 0h — CI/CD | 13 | `.github/workflows/*` | After all apps/packages exist |

## Tasks

### Task 1: Monorepo root scaffold (Turborepo + pnpm)
**Blocks:** 2,3,4,5,6,7,10,11,13  ·  **Blocked by:** —
**Files:**
- Create: `package.json`
- Create: `pnpm-workspace.yaml`
- Create: `turbo.json`
- Create: `.gitignore`
- Create: `.npmrc`
**Steps:**
- [ ] Create root `package.json` with `"private": true`, `"packageManager": "pnpm@9"`, and scripts: `dev` (`turbo dev`), `build` (`turbo build`), `typecheck` (`turbo typecheck`), `lint` (`turbo lint`), `test` (`turbo test`), `db:migrate` (`turbo db:migrate`).
- [ ] Create `pnpm-workspace.yaml` declaring packages globs `apps/*` and `packages/*`.
- [ ] Create `turbo.json` with the pipeline below (verbatim from spec).
- [ ] Create `.gitignore` covering `node_modules`, `dist`, `.turbo`, `.dev.vars`, `.wrangler`, `secrets/`, `tmp/`, `*.local`.
- [ ] Create `.npmrc` with `strict-peer-dependencies=false` and `auto-install-peers=true`.
- [ ] Run `pnpm install` to materialize the workspace lockfile.
**Schema / Interfaces:**
```json
// turbo.json
// PLAN FIX (Wave 0): Turbo 2.x renamed "pipeline" → "tasks".
// Also: packageManager field requires full semver (pnpm@10.33.0, not pnpm@9).
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build":      { "dependsOn": ["^build"], "outputs": ["dist/**", ".astro/**"] },
    "dev":        { "cache": false, "persistent": true },
    "typecheck":  { "dependsOn": ["^build"] },
    "lint":       {},
    "test":       { "dependsOn": ["^build"] },
    "db:migrate": { "cache": false }
  }
}
```
```yaml
# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
```
**Acceptance:**
- [ ] `pnpm install` succeeds; `pnpm turbo run build --dry` lists all workspace tasks.
- [ ] `.dev.vars`, `secrets/`, and `.wrangler` are git-ignored.

### Task 2: `@zync/config` shared tooling package
**Blocks:** 3,4,5,6,7,10,11  ·  **Blocked by:** 1
**Files:**
- Create: `packages/config/package.json`
- Create: `packages/config/tsconfig.base.json`
- Create: `packages/config/eslint.config.mjs`
- Create: `packages/config/tailwind.preset.ts`
- Create: `packages/config/eslint-rules/no-raw-drizzle-from-routes.mjs`
- Create: `packages/config/eslint-rules/require-audit-in-transaction.mjs`
- Create: `packages/config/eslint-rules/require-zod-validation-in-routes.mjs`
- Create: `packages/config/eslint-rules/no-string-equality-for-tokens.mjs`
**Steps:**
- [ ] Name the package `@zync/config`; export `tsconfig.base.json`, `eslint.config.mjs`, `tailwind.preset.ts` via `package.json` `exports`.
- [ ] `tsconfig.base.json`: TypeScript 5 strict mode (`"strict": true`, `"noUncheckedIndexedAccess": true`, `"moduleResolution": "bundler"`, `"target": "ES2022"`), path aliases for `@zync/*`.
- [ ] `eslint.config.mjs`: ESLint 9 flat config wiring the four custom rules below + `@typescript-eslint` + Prettier-compat.
- [ ] Implement custom ESLint rule `no-raw-drizzle-from-routes`: error on importing the raw `db`/Drizzle table objects inside `apps/zync-api/src/routes/**`; only `packages/db/src/queries/**` may import raw tables.
- [ ] Implement `require-audit-in-transaction`: error when a `tx.update`/`tx.insert`/`tx.delete` business write has no paired `tx.insert(auditLog)` in the same transaction scope.
- [ ] Implement `require-zod-validation-in-routes`: error on `c.req.json()` in a route handler not immediately passed to a Zod `.safeParse`/`.parse`.
- [ ] Implement `no-string-equality-for-tokens`: error on direct `===`/`!==` against variables named `*token`, `*hmac`, `*hash`, `*signature`, `*secret`.
- [ ] `tailwind.preset.ts`: shared design tokens / color palette as CSS variables (concrete tokens delivered by `foundation-design-system`; here ship the preset shell exporting a `Config['theme']` extension and the CSS-variable wiring).
**Acceptance:**
- [ ] All four rule ids resolve when referenced from `eslint.config.mjs`; `pnpm --filter @zync/config lint` runs clean.
- [ ] A fixture route importing raw `db` triggers `no-raw-drizzle-from-routes`.

### Task 3: `@zync/types` shared types package
**Blocks:** 4,5,6,7,10,11  ·  **Blocked by:** 2
**Files:**
- Create: `packages/types/package.json`
- Create: `packages/types/src/index.ts`
- Create: `packages/types/src/enums.ts`
- Create: `packages/types/src/branded.ts`
- Create: `packages/types/tsconfig.json`
**Steps:**
- [ ] Name package `@zync/types`; depend on `@zync/config` (dev) only; **no runtime deps** (leaf).
- [ ] Define the shared enums as `const` objects + union types: `TaskStatus`, `TenantTier`, `UserRole`, `InvoiceStatus`, `TicketStatus` (concrete member values are finalized by their owning specs; export the type symbols here as the canonical import site).
- [ ] Define branded ID types `TenantId`, `UserId`, `TaskId` (and the generic `Brand<T, B>` helper) to prevent cross-domain ID mixups.
- [ ] Re-export everything from `src/index.ts`.
**Schema / Interfaces:**
```ts
// packages/types/src/branded.ts
declare const __brand: unique symbol
export type Brand<T, B extends string> = T & { readonly [__brand]: B }
export type TenantId = Brand<string, 'TenantId'>
export type UserId   = Brand<string, 'UserId'>
export type TaskId   = Brand<string, 'TaskId'>

// packages/types/src/enums.ts — canonical import site; owning specs finalize members
export type TaskStatus    = string  // refined by tasks-board-engine
export type TenantTier    = string  // refined by foundation-auth-rbac
export type UserRole      = string  // refined by foundation-auth-rbac
export type InvoiceStatus = string  // refined by invoices-core
export type TicketStatus  = string  // refined by crm-support-center
```
**Acceptance:**
- [ ] `pnpm --filter @zync/types typecheck` passes; package has zero runtime dependencies.
- [ ] Branded types reject assignment of a raw `string` without a cast in a typecheck fixture.

### Task 4: `@zync/auth` package (JWT, RBAC, timing-safe crypto)
**Blocks:** 7,9,10,11  ·  **Blocked by:** 3
**Files:**
- Create: `packages/auth/package.json`
- Create: `packages/auth/src/index.ts`
- Create: `packages/auth/src/jwt.ts`
- Create: `packages/auth/src/rbac.ts`
- Create: `packages/auth/src/crypto.ts`
- Create: `packages/auth/src/session.ts`
**Steps:**
- [ ] Name package `@zync/auth`; deps: `jose`, `@zync/types`. **No DB access** (RBAC helpers are pure functions).
- [ ] `jwt.ts`: HS256 sign/verify helpers over `jose`, reading `JWT_SECRET`.
- [ ] `session.ts`: session type definitions (claims shape, expiry) — concrete claim fields owned by `foundation-auth-rbac`; export the base `Session` type here.
- [ ] `rbac.ts`: pure permission-check helper signatures (no DB).
- [ ] `crypto.ts`: implement `timingSafeEqual` (verbatim below) — the mandated shared comparator for ALL token/HMAC/secret comparisons.
- [ ] Re-export the public surface from `src/index.ts`.
**Schema / Interfaces:**
```ts
// packages/auth/src/crypto.ts — use for ALL token/HMAC comparisons; never === on secrets
export function timingSafeEqual(a: string, b: string): boolean {
  const encoder = new TextEncoder()
  const bufA = encoder.encode(a)
  const bufB = encoder.encode(b)
  if (bufA.byteLength !== bufB.byteLength) return false
  return crypto.subtle.timingSafeEqual(bufA, bufB) // Cloudflare Workers runtime
}
```
**Acceptance:**
- [ ] `timingSafeEqual('x','x') === true`, `timingSafeEqual('x','yy') === false` (length-mismatch short-circuit).
- [ ] `@zync/auth` imports no DB package (verified by dependency graph).

### Task 5: `@zync/db` package (Drizzle scaffold, `createDb`, isolation factories)
**Blocks:** 7,9,10  ·  **Blocked by:** 3
**Files:**
- Create: `packages/db/package.json`
- Create: `packages/db/src/index.ts`
- Create: `packages/db/src/client.ts`
- Create: `packages/db/drizzle.config.ts`
- Create: `packages/db/src/schema/index.ts`
- Create: `packages/db/src/queries/index.ts`
- Create: `packages/db/src/queries/tenant-query.ts`
- Create: `packages/db/src/queries/system-query.ts`
- Create: `packages/db/migrations/.gitkeep`
**Steps:**
- [ ] Name package `@zync/db`; deps: `drizzle-orm`, `@neondatabase/serverless`, `@zync/types`; devDep `drizzle-kit`. Imports ONLY `@zync/types` (no cycles).
- [ ] `client.ts`: implement `createDb(env: Env)` — opens a Neon connection through the Hyperdrive `DB` binding (`env.DB.connectionString`) and returns a Drizzle instance typed over the schema barrel.
- [ ] `schema/index.ts`: empty re-export barrel (one file per domain added by downstream specs).
- [ ] `queries/tenant-query.ts`: implement `tenantQuery(db, tenantId)` factory returning domain helpers pre-bound to `tenantId`; this is the mandatory isolation wrapper — only this module may touch raw Drizzle tables.
- [ ] `queries/system-query.ts`: implement `systemQuery(db)` factory with **no tenant scope**, for `/api/admin/*` routes.
- [ ] `drizzle.config.ts`: Postgres dialect, `schema: './src/schema'`, `out: './migrations'`, `dbCredentials.url` from `DATABASE_URL`.
- [ ] Document in code that all tenant-scoped tables carry `tenant_id UUID NOT NULL` and that Postgres RLS is intentionally deferred to post-v1 (wrapper + lint is the structural defense).
**Schema / Interfaces:**
```ts
// packages/db/src/client.ts
import { drizzle } from 'drizzle-orm/neon-serverless'
import { Pool } from '@neondatabase/serverless'
import type { Env } from '@zync/types' // Env interface re-exported from types
import * as schema from './schema'

export function createDb(env: Env) {
  const pool = new Pool({ connectionString: env.DB.connectionString })
  return drizzle(pool, { schema })
}
export type Db = ReturnType<typeof createDb>

// packages/db/src/queries/tenant-query.ts
export function tenantQuery(db: Db, tenantId: string) {
  // returns domain helpers pre-bound to tenantId; populated by downstream specs.
  return { /* task, expense, invoice, ... added per domain spec */ }
}
// packages/db/src/queries/system-query.ts
export function systemQuery(db: Db) {
  // admin scope, no tenant filter; for /api/admin/*
  return { /* system-level helpers */ }
}
```
**Acceptance:**
- [ ] `createDb` returns a Drizzle instance; `pnpm --filter @zync/db typecheck` passes.
- [ ] Dependency graph shows `@zync/db` importing only `@zync/types`.
- [ ] `pnpm --filter @zync/db exec drizzle-kit generate` runs against the (empty) schema without error.

### Task 6: `@zync/ui` component library shell
**Blocks:** 10,11  ·  **Blocked by:** 2
**Files:**
- Create: `packages/ui/package.json`
- Create: `packages/ui/src/index.ts`
- Create: `packages/ui/tailwind.config.ts`
- Create: `packages/ui/.storybook/main.ts`
- Create: `packages/ui/tsconfig.json`
**Steps:**
- [ ] Name package `@zync/ui`; peerDeps `react`, `react-dom`; dep `@zync/config` (Tailwind preset).
- [ ] `tailwind.config.ts`: extend `@zync/config`'s `tailwind.preset.ts`.
- [ ] `src/index.ts`: export barrel for primitives (`Button`, `Input`, `Badge`, `Card`, `Modal`, …) — component bodies delivered by `foundation-design-system`; ship the barrel + Storybook wiring here.
- [ ] Configure Storybook (dev-only, never deployed) pointing at `src/**/*.stories.tsx`.
**Acceptance:**
- [ ] `pnpm --filter @zync/ui build` produces a typed ESM bundle; Storybook starts in dev.
- [ ] `@zync/ui` consumes the shared Tailwind preset (no duplicated token definitions).

### Task 7: `zync-api` Hono app skeleton + `Env` interface + CORS
**Blocks:** 8,9,10,11  ·  **Blocked by:** 4,5
**Files:**
- Create: `apps/zync-api/package.json`
- Create: `apps/zync-api/src/index.ts`
- Create: `apps/zync-api/src/env.ts`
- Create: `apps/zync-api/src/middleware/cors.ts`
- Create: `apps/zync-api/src/routes/index.ts`
- Create: `apps/zync-api/tsconfig.json`
**Steps:**
- [ ] Name app `zync-api`; deps: `hono`, `zod`, `@zync/db`, `@zync/auth`, `@zync/types`.
- [ ] Define the `Env` binding interface (below) in `src/env.ts`; re-export from `@zync/types` so all Workers/specs type against one source.
- [ ] `src/index.ts`: single Hono app, all routes under `/api/*`; mount CORS + security-headers (Task 8) middleware globally.
- [ ] `cors.ts`: set `Access-Control-Allow-Origin: https://app.zync.is` (also allow `https://admin.zync.is`) + `Access-Control-Allow-Credentials: true` on every response; never use wildcard origin.
- [ ] Wire JWT issuance to an `httpOnly` cookie with `domain=.zync.is; SameSite=Lax; Secure` (parent-domain scoping so `app.zync.is` reads it). Cookie/login logic body delivered by `foundation-auth-rbac`; the cookie attributes + helper live here.
**Schema / Interfaces:**
```ts
// apps/zync-api/src/env.ts — re-exported from @zync/types; downstream Workers type against this
export interface Env {
  DB: Hyperdrive                       // Neon Postgres via Hyperdrive
  STORAGE: R2Bucket                    // file / attachment storage
  KV: KVNamespace                      // session cache, token revocation, counters
  QUEUE: Queue                         // async job dispatch
  AI: Ai                               // Workers AI (OCR, categorization)
  DO_REALTIME: DurableObjectNamespace  // TenantRealtimeDO — per-tenant WS hub
  VECTORIZE: VectorizeIndex            // per-tenant RAG embeddings
  RATE_LIMITER_AUTH: RateLimit
  RATE_LIMITER_WEBHOOK: RateLimit
  RATE_LIMITER_EXPENSE_UPLOAD: RateLimit
  RATE_LIMITER_LEAD_FORM: RateLimit
  ANALYTICS_ENGINE: AnalyticsEngineDataset
  // Secrets
  JWT_SECRET: string
  DATABASE_URL: string
  CRON_SECRET: string
  ADMIN_ENCRYPTION_KEY: string
  INTEGRATION_ENCRYPTION_KEY: string
  RESEND_API_KEY: string
  ANTHROPIC_API_KEY: string
  GOOGLE_OAUTH_CLIENT_ID: string
  GOOGLE_OAUTH_CLIENT_SECRET: string
  MICROSOFT_OAUTH_CLIENT_ID: string
  MICROSOFT_OAUTH_CLIENT_SECRET: string
  UNSUBSCRIBE_HMAC_KEY: string
  CF_CUSTOM_HOSTNAME_API_TOKEN: string
  CF_ANALYTICS_READ_TOKEN: string
  DATA_EXPORT_KEY: string
}
```
**Acceptance:**
- [ ] `GET /api/health` returns 200 with credentialed CORS headers and origin `https://app.zync.is`.
- [ ] Wildcard `*` never appears in any `Access-Control-Allow-Origin` response.

### Task 8: `zync-api` security headers + CSP nonce middleware
**Blocks:** 9  ·  **Blocked by:** 7
**Files:**
- Create: `apps/zync-api/src/middleware/security-headers.ts`
**Steps:**
- [ ] Implement the global security-headers middleware (verbatim below), applied via `app.use('*', …)`.
- [ ] Generate `REQUEST_NONCE` per request: 16 random bytes via `crypto.getRandomValues`, base64url-encoded; inject into the CSP `script-src` directive and expose on context for inline `<script nonce>` tags (used by the dark/light theme SSR flash-prevention script).
- [ ] Ensure ordering: security-headers + CORS run before route handlers.
**Schema / Interfaces:**
```ts
// apps/zync-api/src/middleware/security-headers.ts
app.use('*', (c, next) => {
  c.header('X-Content-Type-Options', 'nosniff')
  c.header('X-Frame-Options', 'DENY')
  c.header('Referrer-Policy', 'strict-origin-when-cross-origin')
  c.header('Permissions-Policy', 'geolocation=(), camera=(), microphone=()')
  c.header('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload')
  c.header(
    'Content-Security-Policy',
    [
      "default-src 'self'",
      "script-src 'self' 'nonce-{REQUEST_NONCE}'",
      "connect-src 'self' https://api.zync.is wss://api.zync.is https://*.firebaseapp.com https://firebasestorage.googleapis.com",
      "img-src 'self' data: https://*.r2.dev",
      "font-src 'self' https://*.r2.dev",
      "frame-ancestors 'none'",
      "base-uri 'self'",
      "form-action 'self'",
    ].join('; ')
  )
  return next()
})
```
**Acceptance:**
- [ ] Every API response carries all six headers; CSP `script-src` contains a fresh base64url nonce per request.
- [ ] `X-Frame-Options: DENY` and `frame-ancestors 'none'` both present.

### Task 9: `zync-api` wrangler bindings (Hyperdrive, R2, KV, AI, DO, Vectorize, RateLimiters, AE) + Smart Placement
**Blocks:** 12  ·  **Blocked by:** 5,8
**Files:**
- Create: `apps/zync-api/wrangler.toml`
**Steps:**
- [ ] Declare all bindings with the exact binding names: `DB` (Hyperdrive→Neon), `STORAGE` (R2), `KV`, `QUEUE`, `AI`, `DO_REALTIME` (class `TenantRealtimeDO`), `VECTORIZE` (namespace `tenant:{id}`), the four RateLimiters, and `ANALYTICS_ENGINE` (`[[analytics_engine_datasets]]`).
- [ ] Add `[smart_placement] mode = "smart"` (routes to PoP nearest Neon; zero cost).
- [ ] Configure `preview` and `production` environments; production Worker Route `zync.is/api/*` (priority over Pages).
- [ ] Note that Workers Paid plan is mandatory (DO + Vectorize require it).
**Schema / Interfaces:**
```toml
# apps/zync-api/wrangler.toml (excerpt — binding names are load-bearing)
# PLAN FIX (Wave 0): wrangler 4.x uses [placement] not [smart_placement].
# PLAN FIX (Wave 0): all [[queues.producers]] entries require a "binding" field.
# PLAN FIX (Wave 0): DO binding requires [[migrations]] section and exported class.
# PLAN FIX (Wave 0): unsafe rate-limiter bindings require type, namespace_id, simple.
name = "zync-api"
main = "src/index.ts"
compatibility_date = "2026-05-30"

[placement]
mode = "smart"

[[hyperdrive]]
binding = "DB"
id      = "PLACEHOLDER_HYPERDRIVE_ID"
[[r2_buckets]]
binding     = "STORAGE"
bucket_name = "zync-storage"
[[kv_namespaces]]
binding = "KV"
id      = "PLACEHOLDER_KV_ID"
[[queues.producers]]
binding = "QUEUE"
queue   = "zync-jobs"
[ai]
binding = "AI"
[[durable_objects.bindings]]
name       = "DO_REALTIME"
class_name = "TenantRealtimeDO"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["TenantRealtimeDO"]
[[vectorize]]
binding    = "VECTORIZE"
index_name = "zync-embeddings"
[[unsafe.bindings]]   # CF native RateLimiter
type         = "ratelimit"
name         = "RATE_LIMITER_AUTH"
namespace_id = "1001"
[unsafe.bindings.simple]
limit = 5
period = 60
[[unsafe.bindings]]
type         = "ratelimit"
name         = "RATE_LIMITER_WEBHOOK"
namespace_id = "1002"
[unsafe.bindings.simple]
limit = 100
period = 60
[[unsafe.bindings]]
type         = "ratelimit"
name         = "RATE_LIMITER_EXPENSE_UPLOAD"
namespace_id = "1003"
[unsafe.bindings.simple]
limit = 20
period = 60
[[unsafe.bindings]]
type         = "ratelimit"
name         = "RATE_LIMITER_LEAD_FORM"
namespace_id = "1004"
[unsafe.bindings.simple]
limit = 3
period = 60
[[analytics_engine_datasets]]
binding = "ANALYTICS_ENGINE"
dataset = "zync-analytics"
```
**Acceptance:**
- [ ] `wrangler deploy --dry-run` validates all bindings; binding names match the `Env` interface exactly.
- [ ] `[smart_placement]` present; production route is `zync.is/api/*`.

### Task 10: `zync-app` Vite + React SPA with lazy module code-splitting
**Blocks:** 13  ·  **Blocked by:** 2,5,6,7
**Files:**
- Create: `apps/zync-app/package.json`
- Create: `apps/zync-app/vite.config.ts`
- Create: `apps/zync-app/wrangler.toml`
- Create: `apps/zync-app/src/main.tsx`
- Create: `apps/zync-app/src/routes/index.tsx`
- Create: `apps/zync-app/src/components/ModuleLoadingSkeleton.tsx`
- Create: `apps/zync-app/index.html`
**Steps:**
- [ ] Name app `zync-app`; deps: `react`, `react-dom`, `react-router` (v7), `zustand`, `@tanstack/react-query` (v5), `@zync/ui`, `@zync/types`. Pure CSR, no SSR.
- [ ] Deploy via Cloudflare Workers serving static assets (`wrangler.toml` assets binding); subdomain `app.zync.is`.
- [ ] Read session JWT from the `httpOnly` cookie set by `zync-api`; configure TanStack Query client to hit `zync-api` with `credentials: 'include'`.
- [ ] Route-based code splitting: auth routes + app shell eager; every feature module (`invoices`, `tasks`, `reports`, … ~30 modules) wrapped in `React.lazy` + `<Suspense fallback={<ModuleLoadingSkeleton />}>`. Module-disabled tenants never download disabled module JS.
- [ ] `vite.config.ts`: set `build.rollupOptions.output.chunkFileNames = '[name]-[hash].js'` for debuggable chunk names (`invoices-abc123.js`).
**Schema / Interfaces:**
```ts
// apps/zync-app/src/routes/index.tsx — pattern for all module routes
import { lazy, Suspense } from 'react'
const InvoicesModule = lazy(() => import('../modules/invoices'))
const TasksModule    = lazy(() => import('../modules/tasks'))
const ReportsModule  = lazy(() => import('../modules/reports'))
// ...all ~30 modules, each wrapped:
// <Suspense fallback={<ModuleLoadingSkeleton />}><InvoicesModule /></Suspense>
```
```ts
// vite.config.ts (excerpt)
export default defineConfig({
  build: { rollupOptions: { output: { chunkFileNames: '[name]-[hash].js' } } },
})
```
**Acceptance:**
- [ ] `pnpm --filter zync-app build` emits per-module named chunks (e.g. `invoices-*.js`), not `chunk-*.js`.
- [ ] App shell loads eagerly; navigating to a module triggers a lazy chunk fetch with the skeleton fallback.

### Task 11: `zync-www` Astro public + auth site
**Blocks:** 13  ·  **Blocked by:** 2,4,6,7
**Files:**
- Create: `apps/zync-www/package.json`
- Create: `apps/zync-www/astro.config.mjs`
- Create: `apps/zync-www/src/pages/index.astro`
- Create: `apps/zync-www/public/_headers`
- Create: `apps/zync-www/src/middleware.ts`
**Steps:**
- [ ] Name app `zync-www`; use Astro with `@astrojs/cloudflare` SSR adapter and `@astrojs/react` for interactive auth islands. Deploys to `zync.is` via Cloudflare Pages.
- [ ] Pages: marketing landing, pricing, docs, and auth flows (login / signup / password reset / invitation accept). Auth components are React islands that POST to `zync-api` — **no auth logic in www**.
- [ ] `public/_headers`: add `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy: geolocation=(), camera=(), microphone=()`, `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload` for `/*`.
- [ ] `src/middleware.ts`: generate per-request CSP nonce and emit the CSP header (Astro middleware is where the nonce can be generated; `_headers` is the static fallback).
**Schema / Interfaces:**
```
# apps/zync-www/public/_headers
/*
  X-Frame-Options: DENY
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: geolocation=(), camera=(), microphone=()
  Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
```
**Acceptance:**
- [ ] `pnpm --filter zync-www build` produces a Cloudflare-adapter output; auth pages render React islands.
- [ ] `_headers` ships all five security headers; CSP emitted from Astro middleware with a nonce.

### Task 12: Queue + Cron declarations, secrets manifest, `.dev.vars` template
**Blocks:** 13  ·  **Blocked by:** 9
**Files:**
- Modify: `apps/zync-api/wrangler.toml`
- Create: `apps/zync-api/.dev.vars.example`
- Create: `docs/infra/secrets.md`
**Steps:**
- [ ] The canonical `Env` interface exposes exactly ONE queue binding (`QUEUE: Queue`). The four load-bearing queue names (`payment.charge`, `campaign.send_batch`, `webhook.deliver`, `export.generate`) are NOT additional Env bindings — they are consumer queue names dispatched to via `env.QUEUE.send({ type: '...' })` message routing. Consumer Workers are declared in their owning specs. Document the reserved names in `docs/infra/secrets.md`.
- [ ] Declare the six cron triggers in `[triggers] crons`: `billing-charge` (daily), `calendar-sync` (daily), `campaigns-send` (every 5 min), `domain-verify` (every 15 min), `data-retention-purge` (monthly), `audit-partition-create` (1st of month). Cron handler bodies live in owning specs; `/api/cron/*` is guarded by `CRON_SECRET`.
- [ ] `.dev.vars.example`: list `JWT_SECRET=`, `DATABASE_URL=`, `CRON_SECRET=` (gitignored real `.dev.vars`).
- [ ] `docs/infra/secrets.md`: document the full Cloudflare secret set (Core, External services, Calendar OAuth, Marketing, CF API tokens, Data) and that per-tenant integration secrets are encrypted in DB via `INTEGRATION_ENCRYPTION_KEY` — never Worker env. Note CF Email Routing is dashboard-only (no `email:*` wrangler OAuth scope).

**DEFECT FIX (Wave 0):** wrangler 4.x requires `[[queues.producers]]` to have a `binding` field. The bare `queue`-only form below is invalid. The correct approach: a single `[[queues.producers]]` with `binding = "QUEUE"` (matching `Env.QUEUE`). The four domain queue names are NOT producer bindings — they are routed via `env.QUEUE.send()` or declared in owning consumer Workers. Schema below updated to reflect actual valid wrangler.toml:

**Schema / Interfaces:**
```toml
# apps/zync-api/wrangler.toml — queue section (binding must match Env.QUEUE exactly)
[[queues.producers]]
binding = "QUEUE"
queue   = "zync-jobs"

# The four domain queue names are reserved for consumer Workers in owning specs.
# They are NOT additional Env bindings. Dispatch via:
#   env.QUEUE.send({ type: 'payment.charge', payload: ... })
# Reserved: payment.charge, campaign.send_batch, webhook.deliver, export.generate

[triggers]
crons = [
  "0 3 * * *",      # billing-charge — daily
  "0 4 * * *",      # calendar-sync — daily
  "*/5 * * * *",    # campaigns-send — every 5 min
  "*/15 * * * *",   # domain-verify — every 15 min
  "0 0 1 * *",      # data-retention-purge — monthly
  "0 1 1 * *",      # audit-partition-create — 1st of month
]
```
**Acceptance:**
- [ ] `wrangler deploy --dry-run` validates queue binding `QUEUE` + crons; four reserved domain queue names documented in `docs/infra/secrets.md`.
- [ ] `.dev.vars.example` committed; real `.dev.vars` is gitignored; `secrets.md` lists every secret in the spec.

### Task 13: CI/CD GitHub Actions pipeline
**Blocks:** —  ·  **Blocked by:** 1,10,11,12
**Files:**
- Create: `.github/workflows/pr.yml`
- Create: `.github/workflows/main.yml`
**Steps:**
- [ ] `pr.yml` (on pull_request): run `pnpm install`; `turbo typecheck lint test`; `pnpm audit --audit-level=high` (fail on high/critical CVEs); wrangler deploy to preview environment; Playwright E2E against the preview URL.
- [ ] `main.yml` (on push to `main`): `turbo typecheck lint test`; then deploy in dependency-safe order — (1) `drizzle-kit migrate` against production Neon, (2) `wrangler deploy` zync-api, (3) `zync-www` + `zync-app` deploy in parallel.
- [ ] Provide Cloudflare + Neon credentials via GitHub secrets; never echo secrets in logs.
**Acceptance:**
- [ ] PR workflow blocks merge on typecheck/lint/test failure or a high/critical `pnpm audit` finding.
- [ ] Main workflow runs `db:migrate` before any Worker deploy; `zync-www`/`zync-app` deploy after `zync-api`.
