# Tenant Public API — Implementation Plan

**Spec:** docs/specs/2026-05-31-tenant-public-api.md  ·  **Slug:** tenant-public-api  ·  **Wave:** 2
**Depends on:** foundation-auth-rbac

## Goal
Deliver a machine-to-machine REST API at `api.zync.is/v1/` that lets Business+ tenants integrate external systems with their Zync account. It exposes scoped, API-key-authenticated read/write access to Customers, Invoices, Tasks, and a read-only webhook-delivery audit log. This plan covers the public API surface only: a dedicated Worker (or hostname subrouter), API-key auth middleware, tier gating, per-key and anti-brute-force rate limiting, cursor pagination, snake_case serialization, the consistent error envelope, and all 13 endpoints. It does NOT build the API-key management UI (owned by `white-label-api`) and adds no new internal `/api/**` routes.

## Architecture
- **New Worker surface** `zync-public-api` (or a hostname-bound subrouter inside `zync-api`) bound to `api.zync.is`. All routes are prefixed `/v1/`. Hono app with a single global auth+ratelimit middleware chain applied before any handler.
- **Auth** authenticates exclusively by API keys. It reads the `tenant_api_keys` table (authoritative schema owned by `white-label-api`): `id, tenant_id, name, key_prefix, key_hash, scopes TEXT[], expires_at, last_used_at, created_by, created_at, revoked_at`. Lookup is by SHA-256 `key_hash`. Tier is resolved from `tenants.tier` (from `foundation-auth-rbac`: `TEXT CHECK (tier IN ('freelancer','business','enterprise','white_label'))`).
- **Tier gate** reuses `meetsMinimumTier(tenantTier, required)` from `packages/auth/src/entitlements.ts` (foundation-auth-rbac). Minimum tier is `business`.
- **Rate limiting** uses a new `RATELIMIT_KV` namespace for the per-key 100 req/min counter, and the existing `RATE_LIMITER_AUTH` binding (shared with the login endpoint — reuse, do not create) for invalid-key brute-force suppression.
- **Data access** consumes existing module tables by the column contracts transcribed in this spec: `customers`, `invoices` + `invoice_lines`, `tasks` + `task_statuses`, `vat_rates`, and `webhook_deliveries`. Every query is hard-scoped with `WHERE tenant_id = $resolvedTenantId`; no query path omits it.
- **Serialization** every response is mapped from internal camelCase Drizzle rows to snake_case JSON via explicit serializers — no camelCase keys leak to consumers. Monetary values serialize as decimal strings (preserving `NUMERIC(12,2)`).
- **Schema deltas owned here:** add `'api'` to the `tasks.source` CHECK constraint; declare `RATELIMIT_KV` in `wrangler.toml`. No new tables.

## Tech Stack
- **App/package:** new `apps/zync-public-api` (Cloudflare Worker, Hono) OR a hostname subrouter mounted in `apps/zync-api`; this plan creates a dedicated worker app `apps/zync-public-api`.
- **Shared packages:** `packages/db` (Drizzle schema + queries), `packages/auth` (`entitlements.ts` → `meetsMinimumTier`), a new `packages/public-api` for serializers, scope logic, pagination, and error helpers reused by handlers and tests.
- **Validation/OpenAPI:** `@hono/zod-openapi` + `zod` — Zod schemas are the source of truth for request/response validation; OpenAPI 3.1 generation wired but docs site deferred.
- **Cloudflare bindings:** `HYPERDRIVE` (Neon Postgres), `RATELIMIT_KV` (new KV namespace), `RATE_LIMITER_AUTH` (existing, shared).
- **Runtime:** Cloudflare Workers; Turborepo + pnpm; Drizzle ORM against Neon Postgres via Hyperdrive.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — scaffolding & schema | 1, 2 | `apps/zync-public-api/*`, `wrangler.toml`, `packages/db/src/schema/tasks.ts`, migrations | No (foundation for all) |
| B — cross-cutting libs | 3, 4, 5, 6 | `packages/public-api/src/*` | Yes (independent modules) |
| C — auth & ratelimit middleware | 7, 8 | `apps/zync-public-api/src/middleware/*` | No (8 depends on 3–6) |
| D — resource handlers | 9, 10, 11, 12 | `apps/zync-public-api/src/routes/*` | Yes (per-resource, after C) |
| E — wiring & OpenAPI | 13 | `apps/zync-public-api/src/index.ts` | No (after D) |

## Tasks

### Task 1: Scaffold `zync-public-api` Worker and `RATELIMIT_KV` binding
**Blocks:** 7, 13  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-public-api/package.json`
- Create: `apps/zync-public-api/wrangler.toml`
- Create: `apps/zync-public-api/tsconfig.json`
- Create: `apps/zync-public-api/src/index.ts` (stub Hono app)
- Create: `apps/zync-public-api/src/env.ts` (typed `Env` binding interface)
**Steps:**
- [ ] Add a new Turborepo workspace `apps/zync-public-api` with deps: `hono`, `@hono/zod-openapi`, `zod`, `drizzle-orm`, workspace `@zync/db`, `@zync/auth`, `@zync/public-api`.
- [ ] In `wrangler.toml` bind the route `api.zync.is/v1/*` to this worker, declare `[[hyperdrive]]` binding `HYPERDRIVE`, a new `[[kv_namespaces]]` binding `RATELIMIT_KV`, and the existing `RATE_LIMITER_AUTH` KV binding (same namespace id used by the login endpoint — reference, do not create a new namespace).
- [ ] Define the `Env` interface: `{ HYPERDRIVE: Hyperdrive; RATELIMIT_KV: KVNamespace; RATE_LIMITER_AUTH: KVNamespace }`.
- [ ] Stub `index.ts` to create a Hono app, expose `GET /v1/health` returning `{ ok: true }`, and `export default app`.
- [ ] Add `dev`, `build`, `deploy` scripts; register the app in the root `turbo.json` pipeline.
**Schema / Interfaces:**
```toml
# apps/zync-public-api/wrangler.toml (excerpt)
name = "zync-public-api"
main = "src/index.ts"
compatibility_flags = ["nodejs_compat"]

routes = [{ pattern = "api.zync.is/v1/*", zone_name = "zync.is" }]

[[hyperdrive]]
binding = "HYPERDRIVE"
id = "<neon-hyperdrive-id>"

[[kv_namespaces]]
binding = "RATELIMIT_KV"
id = "<ratelimit-kv-id>"

[[kv_namespaces]]
binding = "RATE_LIMITER_AUTH"
id = "<existing-auth-rate-limiter-kv-id>"  # shared with login endpoint
```
```ts
// apps/zync-public-api/src/env.ts
export interface Env {
  HYPERDRIVE: Hyperdrive;
  RATELIMIT_KV: KVNamespace;
  RATE_LIMITER_AUTH: KVNamespace;
}
```
**Acceptance:**
- [ ] `pnpm --filter zync-public-api build` succeeds.
- [ ] `wrangler.toml` declares all three bindings and routes only `api.zync.is/v1/*`.
- [ ] `GET /v1/health` returns 200 `{ ok: true }` locally.

### Task 2: Add `'api'` to `tasks.source` enum (schema delta)
**Blocks:** 11  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/tasks.ts`
- Create: `packages/db/migrations/<timestamp>_tasks_source_api.sql`
**Steps:**
- [ ] Extend the `tasks.source` CHECK constraint to include `'api'` alongside the existing values (`'manual'`, `'email'`, `'telegram'`, `'trello'`, etc.).
- [ ] Update the Drizzle column definition and exported `TaskSource` union type to include `'api'`.
- [ ] Write a forward migration that drops and recreates the `source` CHECK constraint with `'api'` added (Postgres requires `ALTER TABLE ... DROP CONSTRAINT ... ; ADD CONSTRAINT ...`).
**Schema / Interfaces:**
```sql
-- migration: widen tasks.source to permit API-created tasks
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS tasks_source_check;
ALTER TABLE tasks ADD CONSTRAINT tasks_source_check
  CHECK (source IN ('manual', 'email', 'telegram', 'trello', 'api'));
```
**Acceptance:**
- [ ] Inserting a task row with `source = 'api'` succeeds; an unknown source value still fails the CHECK.
- [ ] Drizzle `TaskSource` type includes `'api'`.

### Task 3: Error envelope + HTTP status helpers (`packages/public-api`)
**Blocks:** 8, 9, 10, 11, 12  ·  **Blocked by:** —
**Files:**
- Create: `packages/public-api/package.json`
- Create: `packages/public-api/src/errors.ts`
**Steps:**
- [ ] Define the `ApiError` response shape and a `jsonError()` helper that sets the right HTTP status and the consistent envelope.
- [ ] Implement named constructors for every documented error code so handlers never hand-build envelopes.
- [ ] Ensure `422` includes `field`, `403 insufficient_scope` includes `required`, `403 tier_required` includes `minimum_tier`, `429` includes `retry_after`.
**Schema / Interfaces:**
```ts
export interface ApiError {
  error: string;          // machine-readable code (snake_case)
  message: string;        // human-readable (English)
  field?: string;         // 422 only
  required?: string;      // 403 insufficient_scope only
  minimum_tier?: string;  // 403 tier_required only
  retry_after?: number;   // 429 only
}

// Status → error-code map (per spec):
// 400 bad_request | invalid_json
// 401 unauthenticated | invalid_api_key | api_key_expired
// 403 insufficient_scope | tier_required
// 404 not_found
// 409 conflict
// 422 validation_error | invalid_transition | tax_issued_immutable | no_statuses_configured
// 429 rate_limited | too_many_invalid_requests
// 500 internal_error

export const errors = {
  unauthenticated: () => /* 401 */,
  invalidApiKey: () => /* 401 */,
  apiKeyExpired: () => /* 401 */,
  insufficientScope: (required: string) => /* 403 */,
  tierRequired: (minimum_tier: string) => /* 403 */,
  notFound: (message: string) => /* 404 */,
  conflict: (message: string) => /* 409 */,
  validationError: (field: string, message: string) => /* 422 */,
  invalidTransition: (message: string) => /* 422 */,
  taxIssuedImmutable: () => /* 422 */,
  noStatusesConfigured: () => /* 422 */,
  rateLimited: (retry_after: number) => /* 429 */,
  tooManyInvalidRequests: (retry_after: number) => /* 429 */,
  internalError: () => /* 500 */,
};
```
**Acceptance:**
- [ ] Each constructor returns the exact status + envelope keys specified for that error code.
- [ ] No handler in later tasks constructs an error envelope inline.

### Task 4: Cursor pagination helpers
**Blocks:** 9, 10, 11, 12  ·  **Blocked by:** —
**Files:**
- Create: `packages/public-api/src/pagination.ts`
**Steps:**
- [ ] Implement `decodeCursor(cursor?: string): { id: string } | null` — base64-decode JSON, validate shape, treat malformed as `400 bad_request`.
- [ ] Implement `encodeCursor(id: string): string` — base64 of `{ "id": id }`.
- [ ] Implement `clampLimit(raw?: number): number` — default 20, min 1, max 100.
- [ ] Implement `buildPaginated<T>(rows, limit, getId): PaginatedResponse<T>` — query `limit + 1` rows; if more than `limit` returned, `has_more = true`, `next_cursor = encodeCursor(lastReturnedId)`; else `has_more = false`, `next_cursor = null`.
- [ ] Pagination ordering is always `ORDER BY created_at DESC, id DESC`; cursor predicate is keyset on `(created_at, id) < (cursorRow.created_at, cursorRow.id)` resolved via the record's `id`.
**Schema / Interfaces:**
```ts
export interface PaginatedResponse<T> {
  data: T[];
  next_cursor: string | null;
  has_more: boolean;
}
export interface PaginationParams {
  cursor?: string;  // base64 JSON { id: string }
  limit?: number;   // 1–100, default 20
}
```
**Acceptance:**
- [ ] A malformed cursor yields `400 bad_request`.
- [ ] `limit` outside 1–100 is clamped; default is 20.
- [ ] `has_more=false` always pairs with `next_cursor=null`.

### Task 5: Scope matrix + scope-check logic
**Blocks:** 8  ·  **Blocked by:** —
**Files:**
- Create: `packages/public-api/src/scopes.ts`
**Steps:**
- [ ] Define the `ApiScope` union and the per-endpoint required-scope map.
- [ ] Implement `hasScope(grantedScopes: string[], required: ApiScope): boolean` where a write scope implies the matching read scope (e.g. `customers:write` satisfies `customers:read`), but read does not imply write. The granted array is taken verbatim from `tenant_api_keys.scopes`; the implication is applied at check time, not stored.
**Schema / Interfaces:**
```ts
export type ApiScope =
  | 'customers:read' | 'customers:write'
  | 'invoices:read'  | 'invoices:write'
  | 'tasks:read'     | 'tasks:write'
  | 'events:read';

// required scope per (method, path) — matches the spec scope matrix exactly:
// GET    /v1/customers          customers:read
// GET    /v1/customers/:id      customers:read
// POST   /v1/customers         customers:write
// PATCH  /v1/customers/:id      customers:write
// GET    /v1/invoices          invoices:read
// GET    /v1/invoices/:id      invoices:read
// POST   /v1/invoices          invoices:write
// PATCH  /v1/invoices/:id/status invoices:write
// GET    /v1/tasks             tasks:read
// GET    /v1/tasks/:id          tasks:read
// POST   /v1/tasks             tasks:write
// PATCH  /v1/tasks/:id          tasks:write
// GET    /v1/events            events:read

export function hasScope(granted: string[], required: ApiScope): boolean;
// read endpoints accept either the read scope or its matching write scope.
```
**Acceptance:**
- [ ] A key holding only `customers:write` passes `customers:read` checks and fails `invoices:read`.
- [ ] A key holding only `customers:read` fails `customers:write`.

### Task 6: snake_case serializers for all response objects
**Blocks:** 9, 10, 11, 12  ·  **Blocked by:** —
**Files:**
- Create: `packages/public-api/src/serializers.ts`
- Create: `packages/public-api/src/tiptap.ts`
**Steps:**
- [ ] Implement `serializeCustomer(row): CustomerObject`, `serializeInvoice(row, lines): InvoiceObject`, `serializeInvoiceLine(row): InvoiceLineObject`, `serializeTask(row, statusName): TaskObject`, `serializeEvent(row): EventObject`.
- [ ] All keys are snake_case; timestamps serialize as ISO 8601 strings; monetary `NUMERIC` columns serialize as decimal strings (never floats); booleans stay BOOLEAN.
- [ ] In `tiptap.ts`, implement `extractPlainText(doc: unknown): string | null` — flatten Tiptap JSONB to plain text for `task.description_text`; raw JSONB is never exposed. Null doc → null.
- [ ] In `serializeEvent`, truncate `endpoint_url` to scheme + host + first path segment (e.g. `https://hooks.example.com/...`) to avoid leaking secrets embedded in webhook URLs.
**Schema / Interfaces:**
```ts
interface CustomerObject {
  id: string; name: string; company: string | null;
  email: string | null; phone: string | null;
  address: { street: string|null; city: string|null; state: string|null; zip: string|null; country: string|null } | null;
  status: 'active' | 'archived';
  created_at: string; updated_at: string;
}
interface InvoiceObject {
  id: string; customer_id: string; project_id: string | null;
  invoice_number: string | null; proforma_number: string | null;
  status: InvoiceStatus; currency: string;
  issue_date: string | null; tax_issue_date: string | null; due_date: string | null;
  vat_rate: string | null; subtotal: string; vat_amount: string; total: string;
  notes: string | null;
  source: 'manual' | 'retainer' | 'hourly_auto' | 'fixed_deposit' | 'credit_note';
  paid_at: string | null; lines: InvoiceLineObject[];
  created_at: string; updated_at: string;
}
interface InvoiceLineObject {
  id: string; description: string; quantity: string; unit_price: string;
  discount_pct: string; line_total: string; taxable: boolean; position: number;
}
type InvoiceStatus = 'DRAFT'|'SENT'|'APPROVED'|'REJECTED'|'TAX_ISSUED'|'PAID';
interface TaskObject {
  id: string; project_id: string | null; status_id: string; status_name: string;
  title: string; description_text: string | null;
  priority: 'low'|'medium'|'high'|'urgent';
  assignee_id: string | null; reporter_id: string;
  due_date: string | null; estimated_hours: string | null;
  labels: string[]; source: string; created_at: string; updated_at: string;
}
interface EventObject {
  id: string; event_type: string; status: 'pending'|'delivered'|'failed';
  endpoint_url: string; attempt: number; response_status: number | null;
  delivered_at: string | null; created_at: string;
}
```
**Acceptance:**
- [ ] Every serialized key is snake_case; no camelCase leaks.
- [ ] Monetary fields are decimal strings; `task.description_text` is plain text or null.
- [ ] `event.endpoint_url` is truncated to scheme+host+first-segment.

### Task 7: Per-key rate limiting + invalid-key brute-force middleware
**Blocks:** 8  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-public-api/src/middleware/rate-limit.ts`
**Steps:**
- [ ] Implement `perKeyRateLimit(apiKeyId)` using `RATELIMIT_KV` with key `ratelimit:${apiKeyId}:${Math.floor(Date.now()/60_000)}`; fixed 1-minute epoch window (not sliding). Read current count (null = 0); if `>= 100`, return `429 rate_limited` with `retry_after` = seconds to next minute boundary (1–60). Otherwise `KV.put(key, String(count+1), { expirationTtl: 120 })`.
- [ ] On every successful response and on 429, set headers `X-RateLimit-Limit: 100`, `X-RateLimit-Remaining: <100-count>`, `X-RateLimit-Reset: <unix ts of next minute>`.
- [ ] Implement invalid-key suppression using the existing `RATE_LIMITER_AUTH` binding: on each `401 invalid_api_key`, increment `api_key_fail:${sha256(prefix)}:${minuteWindow}` where `prefix` = first 8 chars of the submitted key, and an IP counter. Per-prefix limit: 10 failures/min → `429 too_many_invalid_requests` with `Retry-After: 300`. Per-IP limit: 50 invalid attempts/min across all keys → `429`. Counters expire via KV TTL.
- [ ] `429` body for brute-force: `{ "error": "too_many_invalid_requests", "retry_after": 300 }`.
**Schema / Interfaces:**
```ts
// Per-key window
const key = `ratelimit:${apiKeyId}:${Math.floor(Date.now() / 60_000)}`;
// → on >=100: 429 { error:"rate_limited", message:"Rate limit exceeded. 100 requests per minute allowed.", retry_after }
// Invalid-key (RATE_LIMITER_AUTH):
//   api_key_fail:${sha256(first8(submittedKey))}:${minuteWindow}   limit 10/min → Retry-After: 300
//   api_key_ip:${ip}:${minuteWindow}                               limit 50/min
// Headers on success & 429: X-RateLimit-Limit:100, X-RateLimit-Remaining, X-RateLimit-Reset
```
**Acceptance:**
- [ ] 101st authenticated request within a minute window returns `429 rate_limited` with correct `retry_after` and rate-limit headers.
- [ ] 11 invalid keys sharing a prefix within a minute return `429 too_many_invalid_requests` with `Retry-After: 300`.
- [ ] Invalid-key suppression uses `RATE_LIMITER_AUTH` (no new binding created).

### Task 8: API-key auth middleware (the auth flow)
**Blocks:** 9, 10, 11, 12  ·  **Blocked by:** 3, 5, 7
**Files:**
- Create: `apps/zync-public-api/src/middleware/auth.ts`
**Steps:**
- [ ] Implement global middleware executing the auth flow in exact order:
  1. Extract Bearer token from `Authorization` header → `401 unauthenticated` if missing/malformed.
  2. SHA-256 hash the token (Web Crypto `crypto.subtle.digest`); look up `tenant_api_keys WHERE key_hash = $hash AND revoked_at IS NULL` → `401 invalid_api_key` if not found (and trigger invalid-key suppression from Task 7).
  3. Resolve tenant tier from `tenants` table; if Freelancer (fails `meetsMinimumTier(tier, 'business')`) → `403 tier_required` `{ minimum_tier: "business" }`.
  4. Key expiry: if `expires_at IS NOT NULL AND expires_at < now()` → `401 api_key_expired`.
  5. Per-key rate limit (Task 7) → `429 rate_limited` if exceeded.
  6. Scope check (Task 5) for the matched route → `403 insufficient_scope` `{ required }` if missing.
  7. `last_used_at` update fire-and-forget via `ctx.waitUntil(db.update(tenantApiKeys).set({ lastUsedAt: new Date() }).where(eq(tenantApiKeys.id, keyId)))` — never awaited, never fails the request.
  8. Attach `tenantId`, `apiKeyId`, `createdBy` (key owner user id), and granted `scopes` to Hono context.
- [ ] Tier gate check occurs AFTER key lookup and BEFORE scope check (spec order: lookup → tier → expiry → ratelimit → scope).
- [ ] Use timing-safe comparison is unnecessary for hashed lookup (DB equality on SHA-256 hash), but never log raw keys or hashes.
**Schema / Interfaces:**
```ts
// Context vars set on success:
interface PublicApiContext {
  tenantId: string;     // all downstream queries scoped to this
  apiKeyId: string;
  createdBy: string;    // tenant_api_keys.created_by → used as reporter_id for tasks
  scopes: string[];     // tenant_api_keys.scopes verbatim
}
// tenant_api_keys (read-only, owned by white-label-api):
//   id UUID PK, tenant_id UUID, name TEXT, key_prefix TEXT, key_hash TEXT,
//   scopes TEXT[], expires_at TIMESTAMPTZ, last_used_at TIMESTAMPTZ,
//   created_by UUID, created_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ
```
**Acceptance:**
- [ ] Missing/malformed Authorization → `401 unauthenticated`; unknown/revoked key → `401 invalid_api_key`.
- [ ] Freelancer-tenant key → `403 tier_required minimum_tier: "business"` (checked before scope).
- [ ] Expired key → `401 api_key_expired`.
- [ ] `last_used_at` updates via `ctx.waitUntil` and a DB failure there does not fail the request.
- [ ] Resolved `tenantId` is attached; every later query is scoped to it.

### Task 9: Customers endpoints
**Blocks:** 13  ·  **Blocked by:** 3, 4, 5, 6, 8
**Files:**
- Create: `apps/zync-public-api/src/routes/customers.ts`
**Steps:**
- [ ] `GET /v1/customers` (scope `customers:read`): cursor-paginated list, `ORDER BY created_at DESC, id DESC`, filters `search` (substring on name or email, case-insensitive `ILIKE`), `status` default `'active'`. Always `WHERE tenant_id = $tenantId`.
- [ ] `GET /v1/customers/:id` (scope `customers:read`): single customer scoped to tenant → `404 not_found` if absent.
- [ ] `POST /v1/customers` (scope `customers:write`): validate `name` required (`422 field:name`), `email` valid format if provided (`422 field:email`); insert; return `201 CustomerObject`.
- [ ] `PATCH /v1/customers/:id` (scope `customers:write`): partial update; explicit `null` clears a field, omitted leaves unchanged; setting `status='archived'` while the customer has open invoices → `409 conflict` "Cannot archive customer with open invoices."; return `200 CustomerObject`.
- [ ] All responses go through `serializeCustomer`.
**Schema / Interfaces:**
```ts
interface ListCustomersParams extends PaginationParams { search?: string; status?: 'active'|'archived'; }
interface CreateCustomerBody {
  name: string; company?: string; email?: string; phone?: string;
  address?: { street?: string; city?: string; state?: string; zip?: string; country?: string };
}
interface UpdateCustomerBody {
  name?: string; company?: string | null; email?: string | null; phone?: string | null;
  address?: { street?: string|null; city?: string|null; state?: string|null; zip?: string|null; country?: string|null } | null;
  status?: 'active' | 'archived';
}
// consumes `customers` table (tenant-scoped): id, tenant_id, name, company, email, phone,
//   address fields, status ('active'|'archived'), created_at, updated_at.
// "open invoices" = invoices for this customer whose status NOT IN ('PAID') and not archived.
```
**Acceptance:**
- [ ] List is tenant-scoped, paginated, and honors `search`/`status`.
- [ ] Missing `name` → `422 field:name`; invalid `email` → `422 field:email`.
- [ ] Archiving a customer with open invoices → `409 conflict`.

### Task 10: Invoices endpoints
**Blocks:** 13  ·  **Blocked by:** 3, 4, 5, 6, 8
**Files:**
- Create: `apps/zync-public-api/src/routes/invoices.ts`
**Steps:**
- [ ] `GET /v1/invoices` (scope `invoices:read`): cursor-paginated, filters `customer_id`, `status` (single), `from_date`/`to_date` on `created_at`. Tenant-scoped. Each invoice serialized with its lines.
- [ ] `GET /v1/invoices/:id` (scope `invoices:read`): single invoice + lines → `404` if absent.
- [ ] `POST /v1/invoices` (scope `invoices:write`): validate `customer_id` required (`422`) and belongs to tenant (`404 not_found` "Customer not found."); `lines` non-empty (`422 field:lines`); each `unit_price >= 0` (`422`). Create in `DRAFT`: no invoice/proforma number, `vat_amount=0`, `subtotal` computed from lines, `total = subtotal`. Auto-assign `position` per line if omitted; `quantity` default 1, `discount_pct` default 0, `taxable` default true, `currency` default `'ILS'`. Return `201 InvoiceObject`.
- [ ] `PATCH /v1/invoices/:id/status` (scope `invoices:write`): enforce the allowed transition table; disallowed → `422 invalid_transition`. On `DRAFT→SENT`: assign proforma number atomically and stamp `vat_rate` from `vat_rates` at `issue_date` (no webhook). On `SENT→APPROVED`: emit `invoice.proforma_approved` webhook event. On `SENT→REJECTED` / `REJECTED→DRAFT`: editable resets. On `APPROVED→TAX_ISSUED`: assign sequential invoice number atomically, emit `invoice.issued`, immutable thereafter. On `TAX_ISSUED→PAID`: record payment, emit `invoice.paid`. Any `PATCH` on a `TAX_ISSUED` invoice to a status other than `PAID` → `422 tax_issued_immutable`. Credit-note creation is out of scope for v1. Return `200 InvoiceObject`.
**Schema / Interfaces:**
```ts
type InvoiceStatus = 'DRAFT'|'SENT'|'APPROVED'|'REJECTED'|'TAX_ISSUED'|'PAID';
interface ListInvoicesParams extends PaginationParams {
  customer_id?: string; status?: InvoiceStatus; from_date?: string; to_date?: string;
}
interface CreateInvoiceBody {
  customer_id: string; project_id?: string; due_date?: string; currency?: string;
  notes?: string; lines: CreateInvoiceLineBody[];  // >= 1
}
interface CreateInvoiceLineBody {
  description: string; quantity?: number; unit_price: number;
  discount_pct?: number; taxable?: boolean; position?: number;
}
interface UpdateInvoiceStatusBody { status: InvoiceStatus; }
// Allowed transitions (else 422 invalid_transition):
//   DRAFT→SENT (assign proforma#, stamp vat_rate@issue_date, no webhook)
//   SENT→APPROVED (emit invoice.proforma_approved)
//   SENT→REJECTED ; REJECTED→DRAFT
//   APPROVED→TAX_ISSUED (assign sequential invoice#, emit invoice.issued, immutable)
//   TAX_ISSUED→PAID (emit invoice.paid)
// consumes: invoices (id, tenant_id, customer_id, project_id, invoice_number,
//   proforma_number, status, currency, issue_date, tax_issue_date, due_date,
//   vat_rate, subtotal, vat_amount, total, notes, source, paid_at, created_at, updated_at),
//   invoice_lines (id, invoice_id, description, quantity, unit_price, discount_pct,
//   line_total, taxable, position), and vat_rates (current rate at issue time).
```
**Acceptance:**
- [ ] Create yields a `DRAFT` invoice with no numbers, `vat_amount=0`, `total=subtotal`; empty `lines` → `422 field:lines`; unknown `customer_id` → `404`.
- [ ] Only the six allowed transitions succeed; others → `422 invalid_transition`.
- [ ] `DRAFT→SENT` stamps `vat_rate`; `APPROVED→TAX_ISSUED` assigns a sequential number and the invoice is immutable; `TAX_ISSUED→{non-PAID}` → `422 tax_issued_immutable`.
- [ ] Webhook events emitted for `APPROVED`, `TAX_ISSUED`, `PAID` transitions only.

### Task 11: Tasks endpoints
**Blocks:** 13  ·  **Blocked by:** 2, 3, 4, 5, 6, 8
**Files:**
- Create: `apps/zync-public-api/src/routes/tasks.ts`
**Steps:**
- [ ] `GET /v1/tasks` (scope `tasks:read`): cursor-paginated; filters `project_id`, `assignee_id`, `status_id`, `priority`. Tenant-scoped. Join `task_statuses` to denormalize `status_name`; extract `description_text` from Tiptap JSONB.
- [ ] `GET /v1/tasks/:id` (scope `tasks:read`): single task → `404` if absent.
- [ ] `POST /v1/tasks` (scope `tasks:write`): validate `title` required; `project_id`/`assignee_id` must belong to tenant if provided; default `priority='medium'`. If `status_id` omitted, use the first `task_statuses` row ordered by `position` for the tenant/project; if none configured → `422 no_statuses_configured`. `description` plain text stored as a Tiptap paragraph node. `reporter_id = tenant_api_keys.created_by` (key owner from context); `source = 'api'`. Return `201 TaskObject`.
- [ ] `PATCH /v1/tasks/:id` (scope `tasks:write`): partial update; `assignee_id: null` unassigns; `description: null` clears; `labels` replaces the full set; return `200 TaskObject`.
**Schema / Interfaces:**
```ts
interface ListTasksParams extends PaginationParams {
  project_id?: string; assignee_id?: string; status_id?: string;
  priority?: 'low'|'medium'|'high'|'urgent';
}
interface CreateTaskBody {
  title: string; project_id?: string; status_id?: string;
  priority?: 'low'|'medium'|'high'|'urgent'; assignee_id?: string;
  due_date?: string; estimated_hours?: number; description?: string; labels?: string[];
}
interface UpdateTaskBody {
  title?: string; status_id?: string; priority?: 'low'|'medium'|'high'|'urgent';
  assignee_id?: string | null; due_date?: string | null;
  estimated_hours?: number | null; description?: string | null; labels?: string[];
}
// consumes: tasks (id, tenant_id, project_id, status_id, title, description JSONB,
//   priority, assignee_id, reporter_id NOT NULL, due_date, estimated_hours, labels,
//   source ['manual'|'email'|'telegram'|'trello'|'api'], created_at, updated_at),
//   task_statuses (id, tenant_id/project scope, name, position).
// reporter_id for API tasks := context.createdBy ; source := 'api'.
```
**Acceptance:**
- [ ] Create with omitted `status_id` selects the first status by `position`; no statuses → `422 no_statuses_configured`.
- [ ] API-created tasks have `source='api'` and `reporter_id = key.created_by`.
- [ ] `description_text` in responses is plain text (raw Tiptap JSON never exposed).
- [ ] PATCH `assignee_id:null` unassigns; `labels` replaces the full set.

### Task 12: Events (webhook audit log) endpoint
**Blocks:** 13  ·  **Blocked by:** 3, 4, 6, 8
**Files:**
- Create: `apps/zync-public-api/src/routes/events.ts`
**Steps:**
- [ ] `GET /v1/events` (scope `events:read`): cursor-paginated read-only list of outbound webhook delivery records for the tenant; filters `event_type`, `status` (`pending|delivered|failed`), `from_date`/`to_date`. Tenant-scoped.
- [ ] Serialize via `serializeEvent`: never return the payload; truncate `endpoint_url` to scheme+host+first path segment.
**Schema / Interfaces:**
```ts
interface ListEventsParams extends PaginationParams {
  event_type?: string; status?: 'pending'|'delivered'|'failed';
  from_date?: string; to_date?: string;
}
// consumes: webhook_deliveries (id, tenant_id, event_type, status,
//   endpoint_url, attempt, response_status, delivered_at, created_at).
// payload column intentionally NOT selected/returned.
```
**Acceptance:**
- [ ] List is tenant-scoped, paginated, filterable; payload is never present in responses.
- [ ] `endpoint_url` is truncated to scheme+host+first-segment.

### Task 13: Wire app, mount routes, OpenAPI generation
**Blocks:** —  ·  **Blocked by:** 1, 9, 10, 11, 12
**Files:**
- Modify: `apps/zync-public-api/src/index.ts`
- Create: `apps/zync-public-api/src/openapi.ts`
**Steps:**
- [ ] Build the Hono app with `@hono/zod-openapi`; apply auth middleware (Task 8) globally before all `/v1/**` handlers; mount customers, invoices, tasks, events routers.
- [ ] Enforce `Content-Type: application/json` on bodied requests; malformed JSON → `400 invalid_json`.
- [ ] Add a global error handler mapping uncaught errors to `500 internal_error` (never leak stack traces); unmatched route → `404 not_found`.
- [ ] Generate the OpenAPI 3.1 document from the Zod route schemas (Zod is the source of truth). Docs site at `docs.zync.is/api` and SDK generation are deferred — expose the raw `GET /v1/openapi.json` only.
- [ ] Confirm all responses are `Content-Type: application/json` and snake_case.
**Acceptance:**
- [ ] All 13 endpoints are reachable under `/v1/` and pass through auth + rate-limit middleware.
- [ ] Malformed JSON body → `400 invalid_json`; unknown route → `404 not_found`; uncaught error → `500 internal_error`.
- [ ] `GET /v1/openapi.json` returns a valid OpenAPI 3.1 document covering every endpoint.
