# API Usage Quota & UI — Implementation Plan

**Spec:** docs/specs/2026-05-31-api-usage-quota-ui.md  ·  **Slug:** api-usage-quota-ui  ·  **Wave:** 12
**Depends on:** api-keys-ui, foundation-auth-rbac, white-label-api

## Goal
Add per-API-key monthly request counting, configurable per-key quotas, and a usage dashboard to the existing tenant API key system. Request volume is recorded in Cloudflare Analytics Engine (AE) on every authenticated public-API call; quotas are stored in Postgres on `tenant_api_keys.monthly_quota` and enforced (best-effort) in the public-API auth middleware. The feature delivers a `/settings/api` usage dashboard, a per-key detail page with a 30-day chart and top-endpoints breakdown, and an edit-quota modal. Quotas reset per calendar month (UTC). This is a Business+ tier feature.

## Architecture
This spec extends two upstream specs and introduces no new Postgres table.

- **Schema delta** on the upstream `tenant_api_keys` table (defined in `white-label-api`): one nullable column `monthly_quota INTEGER` (NULL = unlimited).
- **Telemetry** is written to Cloudflare Analytics Engine via a new binding `API_USAGE` (dataset `api_usage`), written on every authenticated request inside the public-API auth flow (the 7-step flow defined in `tenant-public-api` §Auth Flow). One `writeDataPoint` per request with `indexes: [tenantId]`, `blobs: [keyId, endpoint, method]`, `doubles: [1]`.
- **Reads** use the AE SQL HTTP API (`https://api.cloudflare.com/client/v4/accounts/{accountId}/analytics_engine/sql`) authenticated with the existing `CF_ANALYTICS_READ_TOKEN` secret (declared in `foundation-monorepo`, `Account Analytics: Read` scope). A helper package surface (`getMonthlyUsage`, `getKeyUsageSummary`, `getDailyUsage`, `getTopEndpoints`) wraps these queries.
- **Quota enforcement** is added to the public-API auth flow: after key lookup + tier gate + expiry + scope checks, if `monthly_quota IS NOT NULL`, call `getMonthlyUsage(tenantId, keyId)` and return `429 { error: 'quota_exceeded', limit, used }` with a `Retry-After` header set to the first day of next UTC month when `used >= monthly_quota`.
- **API routes** added to the in-app API (`zync-api`, on `app.zync.is`, NOT the public `api.zync.is` surface): `GET /api/api-keys/:id/usage` and `PATCH /api/api-keys/:id/quota`. These reuse `authMiddleware`, `requirePermission`, `requireTier`, and `hasScope`/role checks from `@zync/auth`.
- **UI** adds the `/settings/api` dashboard and `/settings/api/:id` detail page in the React app (`zync-app`), distinct from the key-CRUD page `/settings/api-keys` owned by `api-keys-ui`. The edit-quota modal owns ONLY `monthly_quota`; name/scopes remain owned by spec 60's key manager.

Upstream names consumed verbatim: table `tenant_api_keys` (columns `id`, `tenant_id`, `name`, `key_prefix`, `key_hash`, `scopes`, `last_used_at`, `expires_at`, `created_by`, `created_at`, `revoked_at`); `tenants` (for tier resolution); exports `authMiddleware`, `requirePermission`, `requireTier`, `requireModuleEnabled`, `tenantQuery`, `buildPaginated`, `ApiError`, `@zync/auth`, `@zync/db`, `@zync/types`, `@zync/ui` (`Card`, `Progress`, `Badge`, `Dialog`, `Button`, `Radio`, `Input`, `DataTable`, `EmptyState`, `Skeleton`, `StatCard`, `ErrorState`), `useTierGate`, `useUpgradeModal`, `ANALYTICS_ENGINE` (existing AE binding, reused config pattern), `CF_ANALYTICS_READ_TOKEN`.

## Tech Stack
- **Packages:** `@zync/db` (Drizzle schema delta + migration), `@zync/api-usage` (NEW small package for AE write/read helpers — keeps AE SQL out of routes), `@zync/types` (shared response types).
- **Apps:** `zync-api` (Hono routes + public-API middleware delta), `zync-app` (Vite+React pages, charts).
- **Cloudflare bindings:** `API_USAGE` (NEW Analytics Engine dataset binding, dataset `api_usage`), reuse secret `CF_ANALYTICS_READ_TOKEN`, account id from existing env. Runtime: Cloudflare Workers.
- **Charts:** lightweight bar chart rendered with SVG/CSS (no heavy chart lib) honoring `prefers-reduced-motion` (no entrance animation when reduced).
- **ORM:** Drizzle. **DB:** Neon Postgres via Hyperdrive.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 12.1 | 1 (schema delta), 2 (AE binding + package scaffold) | `packages/db/*`, `packages/api-usage/*`, `wrangler.toml` | Yes (1 and 2 independent) |
| 12.2 | 3 (AE write helper), 4 (AE read helpers) | `packages/api-usage/*` | After 2 |
| 12.3 | 5 (public-API write+enforce middleware), 6 (in-app usage API routes) | `apps/zync-api/*` | 5 after 3+4; 6 after 1+4 (parallel to 5) |
| 12.4 | 7 (usage dashboard page), 8 (key detail + chart page), 9 (edit-quota modal) | `apps/zync-app/*` | After 6; 7/8/9 parallel |
| 12.5 | 10 (shared types export), 11 (i18n strings) | `packages/types/*`, locale files | 10 early-usable; 11 after UI |

## Tasks

### Task 1: Postgres schema delta — `tenant_api_keys.monthly_quota`
**Blocks:** 5, 6, 9  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/api-keys.ts` (the Drizzle table defining `tenant_api_keys`, owned upstream by `white-label-api`)
- Create: `packages/db/migrations/<timestamp>_add_monthly_quota_to_tenant_api_keys.sql`
**Steps:**
- [ ] Add `monthlyQuota` column to the `tenantApiKeys` Drizzle table as `integer('monthly_quota')` (nullable; NULL = unlimited).
- [ ] Generate the forward migration with the DDL below.
- [ ] Do NOT add a CHECK that forbids 0 — spec allows any non-negative integer; add `CHECK (monthly_quota IS NULL OR monthly_quota >= 0)` to reject negatives.
- [ ] Run `drizzle-kit` generate/check so the schema and migration stay in sync.
**Schema / Interfaces:**
```sql
ALTER TABLE tenant_api_keys
  ADD COLUMN monthly_quota INTEGER;  -- NULL = unlimited; enforced per calendar month (UTC)

ALTER TABLE tenant_api_keys
  ADD CONSTRAINT tenant_api_keys_monthly_quota_nonneg
  CHECK (monthly_quota IS NULL OR monthly_quota >= 0);
```
```ts
// packages/db/src/schema/api-keys.ts (delta to existing tenantApiKeys table)
monthlyQuota: integer('monthly_quota'),  // null = unlimited
```
**Acceptance:**
- [ ] Migration applies cleanly to Neon; `monthly_quota` is nullable and defaults to NULL on existing rows.
- [ ] Inserting a negative quota fails the CHECK; NULL and 0 and positive integers succeed.

### Task 2: Analytics Engine binding + `@zync/api-usage` package scaffold
**Blocks:** 3, 4  ·  **Blocked by:** —
**Files:**
- Modify: `apps/zync-api/wrangler.toml` (add `[[analytics_engine_datasets]]` binding `API_USAGE`)
- Create: `packages/api-usage/package.json`
- Create: `packages/api-usage/src/index.ts`
- Create: `packages/api-usage/src/env.ts`
- Modify: `apps/zync-api/src/env.ts` (or shared `Env` type) to add `API_USAGE: AnalyticsEngineDataset` and `CF_ANALYTICS_READ_TOKEN: string` + `CF_ACCOUNT_ID: string` if not already present
**Steps:**
- [ ] Add the AE dataset binding to `wrangler.toml` with binding name `API_USAGE` and dataset name `api_usage`.
- [ ] Create the `@zync/api-usage` workspace package (Turborepo/pnpm) with build wired into the monorepo; depend on `@zync/types`.
- [ ] Define an `ApiUsageEnv` interface exposing `API_USAGE`, `CF_ANALYTICS_READ_TOKEN`, `CF_ACCOUNT_ID`.
- [ ] Re-export all helpers from `src/index.ts` (`writeApiUsage`, `getMonthlyUsage`, `getKeyUsageSummary`, `getDailyUsage`, `getTopEndpoints`, `currentUtcMonthBounds`, `nextUtcMonthStart`).
- [ ] Add `API_USAGE` to the locked `Env` type so it is available to the public-API Worker.
**Schema / Interfaces:**
```toml
# apps/zync-api/wrangler.toml
[[analytics_engine_datasets]]
binding = "API_USAGE"
dataset = "api_usage"
```
```ts
// packages/api-usage/src/env.ts
export interface ApiUsageEnv {
  API_USAGE: AnalyticsEngineDataset;     // write binding
  CF_ANALYTICS_READ_TOKEN: string;       // Account Analytics: Read (read via SQL HTTP API)
  CF_ACCOUNT_ID: string;                 // for the AE SQL HTTP endpoint URL
}
```
**Acceptance:**
- [ ] `pnpm build` resolves the new package and the public-API Worker typechecks with `env.API_USAGE` present.
- [ ] `wrangler.toml` declares the `api_usage` dataset binding.

### Task 3: AE write helper — `writeApiUsage`
**Blocks:** 5  ·  **Blocked by:** 2
**Files:**
- Create: `packages/api-usage/src/write.ts`
**Steps:**
- [ ] Implement `writeApiUsage` that emits exactly one data point per request: `indexes: [tenantId]`, `blobs: [keyId, endpoint, method]`, `doubles: [1]`.
- [ ] `endpoint` is the route pattern (e.g. `/v1/customers`, `/v1/customers/:id`) NOT the raw path with ids, so top-endpoints aggregation groups correctly. Document that callers must pass the matched route template.
- [ ] Make the call non-throwing (wrap in try/catch; log on failure) so telemetry never fails a request.
**Schema / Interfaces:**
```ts
// packages/api-usage/src/write.ts
export function writeApiUsage(
  env: Pick<ApiUsageEnv, 'API_USAGE'>,
  args: { tenantId: string; keyId: string; endpoint: string; method: string },
): void {
  try {
    env.API_USAGE.writeDataPoint({
      indexes: [args.tenantId],
      blobs: [args.keyId, args.endpoint, args.method],
      doubles: [1],
    });
  } catch (e) {
    // telemetry is best-effort; never fail the request
    console.error('writeApiUsage failed', e);
  }
}
```
**Acceptance:**
- [ ] Calling `writeApiUsage` with a stub binding records `indexes=[tenantId]`, `blobs=[keyId, endpoint, method]`, `doubles=[1]`.
- [ ] A throwing binding does not propagate the error.

### Task 4: AE read helpers — monthly usage, daily breakdown, top endpoints
**Blocks:** 5, 6  ·  **Blocked by:** 2
**Files:**
- Create: `packages/api-usage/src/read.ts`
- Create: `packages/api-usage/src/time.ts`
**Steps:**
- [ ] Implement `currentUtcMonthBounds(now?)` → `{ start: Date, end: Date }` (first instant of current UTC month → first instant of next UTC month) and `nextUtcMonthStart(now?)` → `Date`. Used for both queries and the `Retry-After` header.
- [ ] Implement an internal `runAeSql(env, sql)` that POSTs to `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/analytics_engine/sql` with `Authorization: Bearer ${env.CF_ANALYTICS_READ_TOKEN}`, body = raw SQL, parses `{ data: [...] }`.
- [ ] Implement `getMonthlyUsage(env, tenantId, keyId, month?)` → `number` (SUM of `double1`) for the given UTC month, filtered by `index1 = tenantId` AND `blob1 = keyId`.
- [ ] Implement `getDailyUsage(env, tenantId, keyId, month?)` → `{ date: string; count: number }[]` for the last-30-days window of the month (group by `toStartOfDay(timestamp)`), zero-filling missing days client-side or in the helper.
- [ ] Implement `getTopEndpoints(env, tenantId, keyId, month?)` → `{ path: string; method: string; count: number }[]` grouped by `blob2` (endpoint) + `blob3` (method), ordered desc, limited to top 10.
- [ ] Implement `getKeyUsageSummary(env, tenantId, keyId, quota, month?)` → `{ used, quota, remaining }` combining `getMonthlyUsage` with the passed Postgres-sourced quota (`remaining = quota === null ? null : Math.max(0, quota - used)`).
- [ ] AE eventual consistency: document the ~1–2 min query lag in code comments (matches spec Architecture Decisions).
**Schema / Interfaces:**
```ts
// packages/api-usage/src/read.ts
export interface DailyPoint { date: string; count: number }          // date = 'YYYY-MM-DD' (UTC)
export interface EndpointPoint { path: string; method: string; count: number }
export interface KeyUsageSummary { used: number; quota: number | null; remaining: number | null }

export function getMonthlyUsage(
  env: ApiUsageEnv, tenantId: string, keyId: string, month?: Date,
): Promise<number>;

export function getDailyUsage(
  env: ApiUsageEnv, tenantId: string, keyId: string, month?: Date,
): Promise<DailyPoint[]>;

export function getTopEndpoints(
  env: ApiUsageEnv, tenantId: string, keyId: string, month?: Date,
): Promise<EndpointPoint[]>;

export function getKeyUsageSummary(
  env: ApiUsageEnv, tenantId: string, keyId: string, quota: number | null, month?: Date,
): Promise<KeyUsageSummary>;
```
```sql
-- getMonthlyUsage (AE SQL HTTP API; ${...} bound as literals, tenantId/keyId validated as uuid/safe first)
SELECT SUM(double1) AS used
FROM api_usage
WHERE index1 = '${tenantId}'
  AND blob1  = '${keyId}'
  AND timestamp >= toDateTime('${monthStartIso}')
  AND timestamp <  toDateTime('${monthEndIso}');

-- getDailyUsage
SELECT toStartOfDay(timestamp) AS day, SUM(double1) AS count
FROM api_usage
WHERE index1 = '${tenantId}' AND blob1 = '${keyId}'
  AND timestamp >= toDateTime('${windowStartIso}')
  AND timestamp <  toDateTime('${monthEndIso}')
GROUP BY day ORDER BY day ASC;

-- getTopEndpoints
SELECT blob2 AS path, blob3 AS method, SUM(double1) AS count
FROM api_usage
WHERE index1 = '${tenantId}' AND blob1 = '${keyId}'
  AND timestamp >= toDateTime('${monthStartIso}')
  AND timestamp <  toDateTime('${monthEndIso}')
GROUP BY path, method ORDER BY count DESC LIMIT 10;
```
**Acceptance:**
- [ ] `currentUtcMonthBounds` returns correct UTC month boundaries across month/year rollover (Dec→Jan).
- [ ] Each helper issues a single AE SQL request and parses the `data` array into the typed shape.
- [ ] `tenantId`/`keyId` are validated as UUIDs before interpolation (no raw injection into SQL).

### Task 5: Public-API middleware — usage write + quota enforcement (429)
**Blocks:** —  ·  **Blocked by:** 1, 3, 4
**Files:**
- Modify: `apps/zync-api/src/public/auth-middleware.ts` (the `api.zync.is/v1` Bearer auth middleware defined by `tenant-public-api` / `white-label-api`)
**Steps:**
- [ ] After step 5 (scope check) and before step 7 (attach tenantId) in the existing auth flow: load `monthly_quota` for the resolved key (already fetched in the key lookup row — extend the SELECT to include `monthly_quota`).
- [ ] If `monthly_quota IS NOT NULL`: call `getMonthlyUsage(env, tenantId, keyId)`; if `used >= monthly_quota`, return `429` with body `{ error: 'quota_exceeded', limit: monthly_quota, used }` and header `Retry-After` (HTTP-date of `nextUtcMonthStart()`).
- [ ] After a successful (non-429) auth resolution, call `writeApiUsage(env, { tenantId, keyId, endpoint: routePattern, method })` via `ctx.waitUntil()` so it never blocks the response (same fire-and-forget treatment as `last_used_at`).
- [ ] `endpoint` MUST be the matched route pattern (Hono `c.req.routePath` or equivalent), not the raw URL.
- [ ] Preserve all existing checks and error shapes (`unauthenticated`, `invalid_api_key`, `tier_required`, `api_key_expired`, `insufficient_scope`) — only ADD the quota gate and the usage write.
- [ ] Document the best-effort nature: AE query lag (~1–2 min) means a brief overage near the boundary is possible (spec-acknowledged soft quota).
**Schema / Interfaces:**
```ts
// inserted into the existing auth flow, after scope check:
if (apiKey.monthlyQuota !== null) {
  const used = await getMonthlyUsage(env, tenantId, apiKey.id);
  if (used >= apiKey.monthlyQuota) {
    return c.json(
      { error: 'quota_exceeded', limit: apiKey.monthlyQuota, used },
      429,
      { 'Retry-After': nextUtcMonthStart().toUTCString() },
    );
  }
}
// ...after auth resolves, before handler returns:
c.executionCtx.waitUntil(
  Promise.resolve(writeApiUsage(env, {
    tenantId, keyId: apiKey.id, endpoint: c.req.routePath, method: c.req.method,
  })),
);
```
**Acceptance:**
- [ ] A request with `monthly_quota = NULL` is never quota-gated; usage is still written.
- [ ] When `used >= quota`, the request returns `429 { error:'quota_exceeded', limit, used }` with a valid `Retry-After` HTTP-date pointing at the next UTC month start.
- [ ] Usage write happens via `waitUntil` and does not affect response latency or success.
- [ ] All pre-existing auth error responses are unchanged.

### Task 6: In-app API routes — `GET /api/api-keys/:id/usage`, `PATCH /api/api-keys/:id/quota`
**Blocks:** 7, 8, 9  ·  **Blocked by:** 1, 4
**Files:**
- Create: `apps/zync-api/src/routes/api-keys-usage.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount the routes)
**Steps:**
- [ ] Mount both routes behind `authMiddleware` and `requireTier('business')` (Business+; Freelancer → 403 `tier_required`).
- [ ] `GET /api/api-keys/:id/usage`: require permission `settings:read` and role in (OWNER, ADMIN). Load the key via `tenantQuery` (scoped to tenant; 404 if not found in tenant). Parse optional `?month=YYYY-MM` (default current UTC month) with a Zod schema. Return `{ used, quota, remaining, daily, topEndpoints }` by composing `getKeyUsageSummary`, `getDailyUsage`, `getTopEndpoints`. `quota` comes from Postgres `monthly_quota`.
- [ ] `PATCH /api/api-keys/:id/quota`: require permission `settings:write` and role OWNER (OWNER-only — mirrors api-keys-ui OWNER restriction). Validate body `{ monthly_quota?: number | null }` with Zod (`number().int().min(0).nullable().optional()`). UPDATE `tenant_api_keys SET monthly_quota = $1 WHERE id = $id AND tenant_id = $tenantId AND revoked_at IS NULL`. 404 if no row. Return the updated `{ id, monthly_quota }`.
- [ ] Use Zod validation on params/query/body (`require-zod-validation-in-routes`); no raw Drizzle from routes (`no-raw-drizzle-from-routes`) — go through `tenantQuery`/repository helpers.
- [ ] Emit an audit entry for the quota change inside the update transaction (`require-audit-in-transaction`): action `api_key.quota_updated`, target = keyId, before/after quota.
**Schema / Interfaces:**
```ts
// GET /api/api-keys/:id/usage  (Requires: OWNER|ADMIN, Business+, settings:read)
interface ApiKeyUsageResponse {
  used: number;
  quota: number | null;             // null = unlimited
  remaining: number | null;         // null when unlimited
  daily: { date: string; count: number }[];      // last 30 days, UTC, zero-filled
  topEndpoints: { path: string; method: string; count: number }[];
}
const usageQuerySchema = z.object({ month: z.string().regex(/^\d{4}-\d{2}$/).optional() });

// PATCH /api/api-keys/:id/quota  (Requires: OWNER, Business+, settings:write)
const quotaBodySchema = z.object({
  monthly_quota: z.number().int().min(0).nullable().optional(),  // null = unlimited
});
interface ApiKeyQuotaResponse { id: string; monthly_quota: number | null }
```
**Acceptance:**
- [ ] `GET .../usage` returns the typed shape; `?month=2026-04` scopes to April UTC; default is current UTC month.
- [ ] A Freelancer-tier tenant gets `403 tier_required` on both routes.
- [ ] `PATCH .../quota` with role ADMIN is rejected (OWNER-only); with OWNER it updates `monthly_quota`; `null` clears to unlimited; negative is rejected by Zod.
- [ ] Requesting a key id not owned by the tenant returns 404.
- [ ] A quota change writes an audit record in the same transaction.

### Task 7: Usage dashboard page — `/settings/api`
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-app/src/pages/settings/api/ApiUsagePage.tsx`
- Create: `apps/zync-app/src/pages/settings/api/useApiKeyUsageList.ts`
- Modify: `apps/zync-app/src/routes.tsx` (register `/settings/api`)
**Steps:**
- [ ] Add route `/settings/api` (distinct from `/settings/api-keys` owned by api-keys-ui). Gate with `useTierGate('business')`; Freelancer sees the upsell via `useUpgradeModal` (no raw render of the page).
- [ ] List every active key in a `DataTable` with columns: Name, Prefix, Last used (relative), Usage / Quota.
- [ ] For each key, fetch its usage summary (`GET /api/api-keys/:id/usage`) — show `used / quota` (or `used / unlimited`) and a `Progress` bar.
- [ ] Progress bar color thresholds (use design tokens, `no-hardcoded-colors`): green `<70%`, amber `70–90%`, red `>90%`, grey when unlimited.
- [ ] Footer note: "Usage resets monthly (UTC)".
- [ ] Each row links to `/settings/api/:id` (key detail).
- [ ] States: `Skeleton` while loading, `EmptyState` when the tenant has no keys (CTA pointing to `/settings/api-keys` to create one), `ErrorState` on fetch error.
- [ ] a11y: the `Progress` bar exposes `role="progressbar"` with `aria-valuenow/min/max` and an `aria-label` like "Zapier usage: 1,240 of 5,000 requests (24.8%)"; color is not the sole signal — include the percentage text.
**Acceptance:**
- [ ] `/settings/api` lists keys with a usage indicator; quota-less keys show "unlimited" and a grey bar.
- [ ] Progress color matches the threshold bands and exposes accessible labels.
- [ ] Freelancer tenants see the upgrade modal, not the dashboard.
- [ ] Empty/loading/error states render correctly.

### Task 8: Key detail page + usage chart — `/settings/api/:id`
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-app/src/pages/settings/api/ApiKeyDetailPage.tsx`
- Create: `apps/zync-app/src/pages/settings/api/UsageBarChart.tsx`
- Create: `apps/zync-app/src/pages/settings/api/useApiKeyUsage.ts`
- Modify: `apps/zync-app/src/routes.tsx` (register `/settings/api/:id`)
**Steps:**
- [ ] Fetch `GET /api/api-keys/:id/usage` (default current month). Render header with key name, prefix, scopes (read-only here), and quota (`N / month` or `unlimited`).
- [ ] "This month" summary: `{used} requests used | {remaining} remaining` (hide remaining when unlimited).
- [ ] Render `UsageBarChart` from `daily` (last 30 days) as an SVG/CSS bar chart. Honor `prefers-reduced-motion`: skip bar grow/transition animation when reduced.
- [ ] Render "Top endpoints this month" list from `topEndpoints`: `METHOD path  count (pct%)`, percentage of the month's total.
- [ ] [Edit] button opens the edit-quota modal (Task 9). [Revoke] links to / triggers the existing revoke flow owned by api-keys-ui (`DELETE /api/api-keys/:id`) — do not reimplement revoke logic; reuse the upstream action/confirm.
- [ ] States: `Skeleton`, `EmptyState` ("No requests yet this month"), `ErrorState`.
- [ ] a11y: chart bars have an accessible text alternative — a visually-hidden table or `aria-label` per bar ("2026-05-03: 412 requests"); chart container `role="img"` with summary `aria-label`. Top-endpoints list is a real `<ul>`/table, not color-coded only.
**Acceptance:**
- [ ] `/settings/api/:id` shows used/remaining, a 30-day bar chart, and top endpoints with percentages.
- [ ] With `prefers-reduced-motion: reduce`, the chart renders without entrance animation.
- [ ] Chart and endpoint list have accessible text alternatives.
- [ ] [Edit] opens the quota modal; [Revoke] reuses the upstream revoke flow.

### Task 9: Edit-quota modal
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-app/src/pages/settings/api/EditQuotaModal.tsx`
**Steps:**
- [ ] Modal titled "Edit API key quota — {name}". It owns ONLY `monthly_quota` (name/scopes are owned by `/settings/api-keys`, spec 60 — do not render them as editable).
- [ ] Radio group (`Radio` from `@zync/ui`): "Unlimited" (sets `monthly_quota = null`) vs "Limit to [N] requests/month" (numeric `Input`, integer ≥ 1; disabled until the limit radio is selected).
- [ ] On Save: `PATCH /api/api-keys/:id/quota` with `{ monthly_quota }`; on success, close modal, toast, and invalidate the usage queries for the dashboard + detail page.
- [ ] OWNER-only: if the current user is not OWNER, disable Save / hide [Edit] (mirror the server-side OWNER gate so non-owners never see a dead control).
- [ ] Validate client-side: when "Limit to" is chosen, require a positive integer; surface inline error.
- [ ] a11y: radios in a labeled `radiogroup`; the numeric input has an associated `<label>`; focus trapped in `Dialog`; Esc/Cancel closes.
**Schema / Interfaces:**
```ts
// PATCH body matches Task 6
type QuotaForm =
  | { mode: 'unlimited' }                 // → { monthly_quota: null }
  | { mode: 'limited'; value: number };   // → { monthly_quota: value }
```
**Acceptance:**
- [ ] Selecting "Unlimited" + Save sends `{ monthly_quota: null }`; selecting "Limit to 5000" sends `{ monthly_quota: 5000 }`.
- [ ] Non-OWNER users cannot reach a working Save control.
- [ ] On success the usage views refresh to reflect the new quota.
- [ ] Empty/zero/negative limit values are rejected client-side.

### Task 10: Shared response types in `@zync/types`
**Blocks:** 6, 7, 8, 9  ·  **Blocked by:** —
**Files:**
- Create: `packages/types/src/api-usage.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Define and export `ApiKeyUsageResponse`, `ApiKeyUsageDailyPoint`, `ApiKeyUsageEndpointPoint`, `ApiKeyQuotaResponse`, `ApiUsageProgressBand` so server routes and the React app share one contract.
- [ ] Keep these in `@zync/types` so both `zync-api` and `zync-app` import the same shapes.
**Schema / Interfaces:**
```ts
// packages/types/src/api-usage.ts
export interface ApiKeyUsageDailyPoint { date: string; count: number }
export interface ApiKeyUsageEndpointPoint { path: string; method: string; count: number }
export interface ApiKeyUsageResponse {
  used: number;
  quota: number | null;
  remaining: number | null;
  daily: ApiKeyUsageDailyPoint[];
  topEndpoints: ApiKeyUsageEndpointPoint[];
}
export interface ApiKeyQuotaResponse { id: string; monthly_quota: number | null }
export type ApiUsageProgressBand = 'green' | 'amber' | 'red' | 'grey';
```
**Acceptance:**
- [ ] Both apps import these types from `@zync/types` with no local duplication.
- [ ] Route responses and React fetch hooks are typed against the same interfaces.

### Task 11: i18n strings (en + he, RTL)
**Blocks:** —  ·  **Blocked by:** 7, 8, 9
**Files:**
- Modify: locale catalogs under the i18n package (e.g. `packages/i18n/src/locales/en/settings-api.json` and `.../he/settings-api.json`)
**Steps:**
- [ ] Add keys for all visible strings: page titles, column headers (Name, Prefix, Last used, Usage / Quota), "unlimited", "Usage resets monthly (UTC)", "{used} requests used", "{remaining} remaining", "Top endpoints this month", "Daily requests (last 30 days)", modal title/labels ("Unlimited", "Limit to {n} requests/month", "Save changes", "Cancel"), empty/error state copy.
- [ ] Provide Hebrew translations; ensure numbers/percentages format via the locale formatter; verify layout under RTL (`dir="rtl"`) — progress bar fill direction and chart axis mirror correctly.
- [ ] No hardcoded user-facing strings in components (use the translation hook).
**Acceptance:**
- [ ] All `/settings/api` and `/settings/api/:id` strings resolve from the catalog in both en and he.
- [ ] Under Hebrew/RTL the dashboard, chart, and modal render correctly (mirrored, readable).
