# Foundation: Monorepo & Infrastructure

**Date:** 2026-05-30  
**Status:** Draft  
**Depends on:**   
**Referenced by:** All other specs

---

## Overview

Establishes the Turborepo monorepo, Cloudflare deployment topology, shared packages, and tooling baseline that all other specs build on.

---

## Repository Structure

```
zync-monorepo/
├── apps/
│   ├── zync-www/          # Astro — public site + auth entry (Cloudflare Pages)
│   ├── zync-app/          # Vite + React — tenant app (Cloudflare Workers)
│   └── zync-api/          # Hono — API server (Cloudflare Workers)
├── packages/
│   ├── ui/                # Shared React component library (design system primitives)
│   ├── db/                # Drizzle ORM schema + migrations + query helpers
│   ├── types/             # Shared TypeScript types (DTOs, enums, branded types)
│   ├── auth/              # Auth utilities (JWT helpers, session types, RBAC primitives)
│   └── config/            # Shared tooling configs (eslint, tsconfig, tailwind preset)
├── turbo.json
├── pnpm-workspace.yaml
└── package.json
```

---

## Apps

### zync-www (Astro)
- Deploys to `zync.is` via Cloudflare Pages
- Serves: marketing landing page, pricing, docs, auth flows (login / signup / password reset / invitation accept)
- Astro islands for interactive auth components (React)
- SSR adapter: `@astrojs/cloudflare`
- Auth pages post to `zync-api` — no auth logic in www itself

### zync-app (Vite + React)
- Deploys to `app.zync.is` via Cloudflare Workers (static assets served from Worker)
- SPA: all routes client-side via react-router v7
- State: Zustand for UI + board preferences
- Data: TanStack Query v5 hitting `zync-api`
- Session: reads JWT from `httpOnly` cookie set by `zync-api`
- No SSR — pure CSR SPA
- **Route-based code splitting:** every module route uses `React.lazy` + `Suspense`. Auth routes and app shell load eagerly; all feature modules (invoices, tasks, reports, etc.) are lazy chunks. Module-disabled tenants never download disabled module JS.

```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 in <Suspense fallback={<ModuleLoadingSkeleton />}>
```

Chunk naming: Vite config `build.rollupOptions.output.chunkFileNames = '[name]-[hash].js'` — gives `invoices-abc123.js`, not `chunk-abc123.js`, for debuggability.

### zync-api (Hono)
- Deploys as Cloudflare Worker, served under `zync.is/api/*` via Cloudflare Worker Routes (takes priority over Pages)
- Single Hono app; all API routes live here under `/api/` prefix
- The API runs on Cloudflare's Free stateless pool; ordinary authenticated requests must remain below its 10 ms CPU ceiling and are monitored through execution traces.
- Connects to: Neon (Postgres via `@neondatabase/serverless`), Cloudflare R2, Cloudflare KV, Cloudflare Queues
- Issues signed JWTs, sets `httpOnly` cookie with `domain=.zync.is; SameSite=Lax; Secure` (parent-domain scoping allows app.zync.is to read it)
- Handles file uploads, webhook delivery, cron jobs

---

## Infrastructure (Cloudflare)

| Resource | Purpose | Binding name |
|----------|---------|--------------|
| Neon PostgreSQL | Primary database | `DB` (via Hyperdrive) |
| Cloudflare R2 | File / attachment storage | `STORAGE` |
| Cloudflare KV | Session cache, token revocation, report version counters | `KV` |
| Cloudflare Queues | Async job dispatch | `QUEUE` (see queue list below) |
| Cloudflare Workers AI | OCR, AI categorization | `AI` |
| Cloudflare Durable Objects | Per-tenant WebSocket hub (`TenantRealtimeDO`) | `DO_REALTIME` |
| Cloudflare Vectorize | Per-tenant RAG embeddings (namespace `tenant:{id}` + metadata) | `VECTORIZE` |
| CF native RateLimiter | Auth rate limiting | `RATE_LIMITER_AUTH` |
| CF native RateLimiter | Inbound webhook rate limiting | `RATE_LIMITER_WEBHOOK` |
| CF native RateLimiter | Expense OCR upload throttle (10/min per user) | `RATE_LIMITER_EXPENSE_UPLOAD` |
| CF native RateLimiter | Public lead form abuse protection (20/min per form) | `RATE_LIMITER_LEAD_FORM` |

Rationale (2026-07-12): Cloudflare rejected a higher configured CPU limit because this account is on the Free plan, so authenticated hot paths must be optimized or narrowly offloaded within the 10 ms stateless ceiling.
| CF Analytics Engine | Funnel + marketing events (write via binding, read via SQL HTTP API) | `ANALYTICS_ENGINE` (`[[analytics_engine_datasets]]`) |

> ⚠️ **CF Email Routing** (spec 14 tickets + spec 17 expenses): dashboard-only setup — `email:*` scope absent from wrangler OAuth. Single email Worker routes by `to` address: `expenses@{slug}.zync.is` → expenses queue; `support@{slug}.zync.is` → tickets queue.

### Queues

| Queue | Consumer | Purpose | Owner spec |
|-------|----------|---------|------------|
| `payment.charge` | billing Worker | Async charge + invoice creation + retry | spec 18 |
| `campaign.send_batch` | campaigns Worker | Batched email delivery (50 recipients/job) | spec 23 |
| `webhook.deliver` | webhook Worker | Async outbound webhook delivery, exponential backoff | spec 27 |
| `export.generate` | export Worker | Full tenant data export (DB + R2 → encrypted ZIP) | spec 28 |

### Cron Triggers

| Cron | Schedule | Purpose | Owner spec |
|------|----------|---------|------------|
| `billing-charge` | Daily | Auto-charge due payment plans; enqueues `payment.charge` | spec 18 |
| `calendar-sync` | Daily | Full Google/Outlook re-sync (catches webhook gaps) | spec 19 |
| `campaigns-send` | Every 5 min | Process due/scheduled campaigns; enqueues send batches | spec 23 |
| `domain-verify` | Every 15 min | DNS check + CF custom hostname provisioning | spec 27 |
| `data-retention-purge` | Monthly | Purge expired PII, export ZIPs, session tokens | spec 28 |
| `audit-partition-create` | 1st of month | Create next month's `audit_log` partition | spec 28 |

### Database: Neon PostgreSQL
- Multi-tenant row isolation via `tenant_id` foreign key on every tenant-scoped table (enforced at ORM query layer, not RLS)
- Drizzle ORM — type-safe schema, migrations via `drizzle-kit`
- Connection via Cloudflare Hyperdrive for connection pooling at edge
- Single database, single schema, all tenants co-located

### Query Patterns

Common patterns for querying tenant-scoped data via the `tenantQuery` factory. All queries run within the mandatory wrapper (see Multi-tenant Isolation Model). Pattern examples:

```ts
// Single record by ID
const task = await tenantQuery(db, tenantId).task.byId(taskId)

// List with pagination
const { rows, count } = await tenantQuery(db, tenantId).expense.list({ limit: 20, offset: 0 })

// Complex filter (see below for JSONB patterns)
const active = await tenantQuery(db, tenantId).invoice.where({ status: 'active' })
```

### JSONB GIN Index Mandate

Any JSONB column used in a `WHERE` clause **must** have a GIN index. Full table scans on JSONB are quadratic at scale and will not survive tenant growth.

```sql
-- Required pattern: GIN index for every JSONB column used in WHERE
CREATE INDEX CONCURRENTLY idx_{table}_{column}_gin
  ON {table} USING gin({column});

-- Examples of mandatory indexes:
CREATE INDEX CONCURRENTLY idx_expenses_metadata_gin
  ON expenses USING gin(metadata);

CREATE INDEX CONCURRENTLY idx_tasks_custom_fields_gin
  ON tasks USING gin(custom_fields);

CREATE INDEX CONCURRENTLY idx_invoices_line_items_gin
  ON invoices USING gin(line_items);
```

**Rule:** migrations that add a JSONB column used in a WHERE clause must include the GIN index in the same migration file. PR review must verify this.

**Performance floor:** `@>` containment queries and `->>` key lookups on GIN-indexed JSONB run in O(log n) with 200–1000× latency improvement over seq-scan at 100k rows.

**Covered patterns:**
- `WHERE metadata @> '{"key": "value"}'` — containment, requires GIN
- `WHERE custom_fields->>'status' = 'active'` — key access in WHERE, requires GIN
- `SELECT metadata->>'key' FROM ...` — key access in SELECT only — GIN not required (no WHERE), B-tree on the extracted value if sorted/filtered

### Why Neon over D1
- PostgreSQL feature set needed: JSONB columns, full-text search, complex joins, aggregates
- Drizzle Postgres dialect vs D1's SQLite dialect — avoids split migration story
- Neon serverless driver compatible with Cloudflare Workers edge runtime

---

## Shared Packages

### `packages/db`
```
packages/db/
├── src/
│   ├── schema/          # One file per domain (users.ts, tenants.ts, tasks.ts, ...)
│   ├── migrations/      # Drizzle-kit generated
│   ├── queries/         # Reusable query helpers per domain
│   └── index.ts         # Re-exports schema + createDb(env) factory
└── drizzle.config.ts
```
- `createDb(env: Env)` accepts Worker bindings, returns Drizzle instance
- All schema files export typed table definitions
- No circular deps: db package imports only `packages/types`

### `packages/types`
- Shared DTOs (request/response shapes)
- Enums: `TaskStatus`, `TenantTier`, `UserRole`, `InvoiceStatus`, `TicketStatus`, etc.
- Branded types: `TenantId`, `UserId`, `TaskId`, etc. (prevent ID mixups across domains)

### `packages/auth`
- JWT sign/verify helpers (using `jose` — Web Crypto compatible)
- Session type definitions
- RBAC permission check helpers (pure functions, no DB access)
- Invitation token generation/validation

### `packages/ui`
- React component library — all primitives
- Tailwind CSS (shared preset from `packages/config`)
- Exports: Button, Input, Badge, Card, Modal, etc. (see design-system spec)
- Storybook for component development (dev only, not deployed)

### `packages/config`
- `eslint.config.mjs` — shared flat config
- `tsconfig.base.json` — strict mode, path aliases
- `tailwind.preset.ts` — shared design tokens, color palette (CSS variables)

---

## Tooling

| Tool | Version | Purpose |
|------|---------|---------|
| Turborepo | latest | Task pipeline, remote caching |
| pnpm | 9.x | Package manager (workspaces) |
| TypeScript | 5.x | Strict mode across all packages |
| ESLint | 9.x flat config | Linting |
| Prettier | 3.x | Formatting |
| Vitest | latest | Unit tests (packages + api) |
| Playwright | latest | E2E tests (zync-app) |
| Drizzle Kit | latest | DB migrations |
| Wrangler | 4.x | Cloudflare Workers dev + deploy |

### Turbo Pipeline (`turbo.json`)

```json
{
  "pipeline": {
    "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
    "dev": { "cache": false, "persistent": true },
    "typecheck": { "dependsOn": ["^build"] },
    "lint": {},
    "test": { "dependsOn": ["^build"] },
    "db:migrate": { "cache": false }
  }
}
```

---

## Environment & Secrets

### `.dev.vars` (Wrangler local dev secrets — gitignored)
```
JWT_SECRET=
DATABASE_URL=         # Neon connection string
CRON_SECRET=
```

### Cloudflare Secrets (via wrangler secret put)

**Core:**
- `JWT_SECRET` — HS256 signing key
- `DATABASE_URL` — Neon connection string
- `CRON_SECRET` — protects `/api/cron/*` endpoints
- `ADMIN_ENCRYPTION_KEY` — AES-256 key for encrypting TOTP secrets in `admin_users` table
- `INTEGRATION_ENCRYPTION_KEY` — AES-256 key for encrypting ALL tenant credentials in DB: SMTP/OAuth tokens, per-tenant Telegram bots, webhook signing keys, adapter API keys

**External services:**
- `RESEND_API_KEY` — system transactional email delivery (Resend)
- `ANTHROPIC_API_KEY` — Claude Sonnet AI chat + Vision OCR categorization

**Calendar OAuth (Zync's registered app credentials — global):**
- `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET` — Google Calendar OAuth app
- `MICROSOFT_OAUTH_CLIENT_ID`, `MICROSOFT_OAUTH_CLIENT_SECRET` — Outlook/MS Graph OAuth app
- Per-user tokens encrypted in `calendar_connections` via `INTEGRATION_ENCRYPTION_KEY`

**Marketing:**
- `UNSUBSCRIBE_HMAC_KEY` — HMAC key for stateless unsubscribe tokens (never logged; `HMAC-SHA256(subscriberId:tenantId)`)

**Cloudflare API tokens (manual CF dashboard creation — not in wrangler OAuth scope):**
- `CF_CUSTOM_HOSTNAME_API_TOKEN` — `ssl_certs:write` scope; custom hostname provisioning for Enterprise custom domains
- `CF_ANALYTICS_READ_TOKEN` — `Account Analytics: Read` scope; AE SQL HTTP API reads (funnel dashboards, analytics widgets). AE binding is write-only.

**Data:**
- `DATA_EXPORT_KEY` — AES-256-GCM key for encrypting tenant export ZIPs

> `TELEGRAM_BOT_TOKEN` removed — per-tenant bot tokens encrypted in DB via `INTEGRATION_ENCRYPTION_KEY` (see spec 14).
> Per-integration secrets (Morning API key, Isracard, etc.) stored encrypted in `adapter_credentials` — not Worker env secrets.

### CORS configuration
`zync-api` sets `Access-Control-Allow-Origin: https://app.zync.is` (and `https://admin.zync.is`) + `Access-Control-Allow-Credentials: true` on all API responses. Credentials must flow for `httpOnly` cookie auth. Wildcard origin (`*`) is never used (incompatible with `Allow-Credentials`).

### Environment tiers
- `local` — wrangler dev, local Neon branch
- `preview` — PR deployments via Cloudflare preview environments
- `production` — `main` branch auto-deploys

---

## CI/CD

Platform: GitHub Actions

### On PR:
1. `turbo typecheck lint test` — all packages
2. `pnpm audit --audit-level=high` — fail PR on high/critical CVEs in dependencies
3. Wrangler deploy to preview environment
4. Playwright E2E against preview URL

### On merge to `main`:
1. `turbo typecheck lint test`
2. `wrangler deploy` — zync-api, zync-app, zync-www
3. `drizzle-kit migrate` — runs pending migrations against production Neon

### Deploy order (dependency-safe):
1. `db:migrate`
2. `zync-api` deploy
3. `zync-www` + `zync-app` deploy (parallel)

---

## Dev Workflow

```bash
pnpm install
pnpm turbo dev          # starts all apps in parallel
pnpm turbo typecheck    # type check all
pnpm turbo lint         # lint all
pnpm turbo test         # vitest all
pnpm db:migrate         # apply pending migrations (local Neon branch)
```

---

## Multi-tenant Isolation Model

All tenant-scoped tables carry `tenant_id UUID NOT NULL`.

### Mandatory query wrapper
`packages/db/src/queries` exports a `tenantQuery(db, tenantId)` factory that returns domain query helpers pre-bound to `tenantId`. Every API route handler **must** use this factory — never call raw Drizzle table directly from route handlers. An ESLint rule (`no-raw-drizzle-from-routes`) enforces this: import of `db` directly in `apps/zync-api/src/routes/**` is a lint error. Only `packages/db/src/queries/**` may import raw Drizzle table objects.

This single-wrapper pattern is the primary isolation defense. Cross-tenant queries are structurally impossible via this API, not just by convention.

### System admin bypass
SYSTEM_ADMIN routes live under `/api/admin/*` — guarded by `adminOnly` middleware. These routes receive a `systemQuery(db)` factory instead, which has no tenant scope. No overlap with tenant-scoped routes.

### Audit write requirement
Every write route handler **must** insert an audit record in the **same DB transaction** as the business operation. This is a cross-cutting constraint from spec 28. ESLint rule `require-audit-in-transaction` enforces it: a `tx.update/insert/delete` without a paired `tx.insert(auditLog)` in scope is a lint error. Applies to all specs 9–27.

### Defense-in-depth note
Postgres RLS is not added in v1. The mandatory-wrapper + lint rule provides structural enforcement. RLS can be layered later if compliance requirements grow. Document this explicitly in the `audit-compliance` spec.

---

## Cost Analysis

### Workers Paid plan — required

Durable Objects and Vectorize are Workers Paid features. Workers Paid ($5/mo) is mandatory from day one.

Workers Paid includes:
- 10M requests/month (then $0.30/M)
- 10M KV reads/day, 1M KV writes/day, 1GB KV storage
- DOs: $0.15/M requests, $0.20/GB-month storage, $12.50/M GB-seconds
- Queues: 1M ops/month (then $0.40/M)
- R2: 10GB storage free, 1M Class A (write) ops/month, 10M Class B (read) ops/month free

### KV budget

**Risk without mitigation: per-request KV reads exhaust quota at scale.**

| Scenario | Raw KV reads/day | Workers Paid limit | Verdict |
|----------|-----------------|-------------------|---------|
| 10 RPS, no cache | 864k | 10M | OK but growing |
| 100 RPS, no cache | 8.6M | 10M | Danger |
| 1k active users, cache.default 60s TTL | ~1.44M | 10M | Safe |

**Mitigation implemented:**
1. Auth revocation: `cache.default` (60s TTL per PoP) in front of KV → cache hit = 0 KV reads (see `foundation-auth-rbac`)
2. Rate limiting: CF native `RateLimiter` binding → 0 KV reads/writes (see `system-communications-notifications`)

KV usage after mitigation: KV stores only true state (user versions, feature flags). Reads happen only on cache miss, at most once per 60s per user per PoP.

### Smart Placement

Add `[smart_placement] mode = "smart"` to `wrangler.toml`. Routes requests to PoP nearest Neon. Saves ~50ms DB round-trip → less wall time per request → lower CPU pressure on bundled plan. Zero cost to enable.

### Monthly estimate (100 tenants, 500 users)

| Service | ~Cost/month |
|---------|------------|
| Workers Paid | $5 |
| Neon (serverless scale-to-zero) | ~$0–19 |
| R2 (< 10GB) | $0 |
| Queues | $0 (included) |
| Workers AI (OCR at low volume) | ~$0–5 |
| Vectorize ($0.04/M queries) | ~$1–5 |
| Resend (transactional email) | ~$0–20 |
| **Total** | **~$6–54/month** |

At 1,000+ tenants, Neon and Workers requests dominate; Workers adds $0.30/M requests over included quota.

---

## Security Standards

### HTTP Response Security Headers

`zync-api` (Hono) must add the following headers on every response via global middleware:

```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}'",   // nonce injected per request
      "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()
})
```

`REQUEST_NONCE`: a 16-byte random value generated per-request (`crypto.getRandomValues`), base64url-encoded, injected into both the `script-src` directive and any inline `<script nonce="...">` tags (see dark/light theme spec — SSR flash-prevention script must use this nonce).

`zync-www` (Astro / Cloudflare Pages) adds equivalent headers via `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
```

(CSP for `zync-www` is defined in Astro middleware where the nonce can be generated; `_headers` file covers the static fallback.)

### Input Validation Mandate

All `zync-api` route handlers **must** parse request bodies through a **Zod schema** before touching any business logic. Raw `c.req.json()` without validation is a lint error (`require-zod-validation-in-routes`).

```ts
// Pattern — every POST/PATCH/PUT handler:
const body = await c.req.json()
const parsed = MyRequestSchema.safeParse(body)
if (!parsed.success) return c.json({ error: parsed.error.flatten() }, 400)
// proceed with parsed.data only
```

`packages/db/src/queries/**` may assume pre-validated inputs — validation is the route layer's responsibility.

### Cryptographic Comparison Mandate

All HMAC, token, and secret comparisons must use timing-safe equality. Direct `===` on security-sensitive strings leaks timing information and is forbidden.

Shared helper — implement in `packages/auth/src/crypto.ts`:

```ts
// Use for ALL token/HMAC comparisons — never use === on secrets directly
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)  // available in Cloudflare Workers runtime
}
```

Applies to: webhook HMAC verification (spec 27), payment webhook signatures (spec 49), magic link tokens (spec 13), API key comparison (spec 39).

Enforcement: ESLint rule `no-string-equality-for-tokens` — flag direct `===` on variables named `*token`, `*hmac`, `*hash`, `*signature`, `*secret`.

## Non-goals

- No SSR for zync-app (CSR SPA only — avoids Worker CPU quotas for React rendering)
- No Prisma — Drizzle only
- No monorepo-level ORM migrations from multiple packages — only `packages/db` owns schema
- No Docker for local dev — Wrangler + Neon local branch is sufficient

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Database | Neon PostgreSQL via Hyperdrive | Full SQL feature set needed; D1 too limited for multi-tenant SaaS |
| Monorepo tool | Turborepo | Native pnpm workspace support, fast remote caching |
| Package manager | pnpm | Workspace support, disk efficiency, strict hoisting |
| API subdomain | `zync.is/api` path (Worker Route) | Matches Zync.txt; cookie `domain=.zync.is` covers app.zync.is too |
| API framework | Hono | First-class Cloudflare Workers support, typed routes, middleware |
| ORM | Drizzle | Type-safe, edge-compatible, works with Neon serverless driver |
| Async jobs | Cloudflare Queues | Native CF integration, no external broker needed |
| File storage | Cloudflare R2 | Co-located with Workers, no egress costs, S3-compatible |
