# wp-back API SaaS (Cloudflare Workers) — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Backend SaaS qui active les licences, broke l'accès Wasabi via presigned URLs, mesure l'usage par site et applique quotas + rétention (GC).

**Architecture:** Un Worker Cloudflare (Hono, TypeScript) devant D1. Le plugin WordPress ne voit jamais de credentials S3 : il demande des presigned URLs scopées objet par objet ; le backend construit toutes les clés S3 (`s/{site_id}/blobs/{sha256}`), vérifie quotas au moment de la signature et réconcilie les tailles réelles au confirm (HEAD S3). Un cron quotidien fait le mark-and-sweep de rétention.

**Tech Stack:** Cloudflare Workers, Hono, D1, aws4fetch (SigV4/presign), hono/jwt (HS256), nanoid, Vitest + @cloudflare/vitest-pool-workers, Oxlint/Oxfmt, pnpm workspace, wrangler.

## Global Constraints

- TypeScript `strict: true`, aucun `any` non justifié.
- **Aucune clé S3 construite hors de `api/src/lib/wasabi.ts`** (fonction `blobKey(siteId, sha256)` = `s/{siteId}/blobs/{sha256}`).
- Presigned URLs : TTL **900 s**, scopées à un objet et une méthode. **Jamais** de LIST ni DELETE presignés côté client.
- Modèle de menace : le site WordPress est compromis. Le client ne fournit jamais de chemin S3 ; un PUT sur un blob déjà `stored` → `{status:"exists"}` (pas de ré-upload ni d'écrasement).
- Taille max d'objet : `MAX_OBJECT_BYTES = 5_368_709_120` (5 GiB).
- JWT site token : HS256 via `hono/jwt`, claims `{sub: site_id, ver: token_version, exp: now + 30 jours}`, secret `JWT_SECRET`.
- Secrets : `WASABI_ACCESS_KEY_ID`, `WASABI_SECRET_ACCESS_KEY`, `JWT_SECRET` (wrangler secrets en prod ; bindings miniflare en test).
- Endpoint Wasabi : `https://s3.{region}.wasabisys.com` (région lue dans la table `buckets`).
- Erreurs API : JSON `{error: {code, message}}` ; 400 requête invalide, 401 token invalide/périmé, 402 quota dépassé, 403 licence invalide, 404 ressource étrangère au site, 409 taille discordante, 429 rate limit.
- Toute nouvelle dépendance s'installe via **`sfw pnpm add <pkg>`** (jamais `pnpm add` nu).
- Commits : Conventional Commits (`feat(api): …`), un commit par tâche minimum.
- Tests : Vitest via `@cloudflare/vitest-pool-workers` ; commandes lancées depuis la racine du repo avec `pnpm -C api …`.
- Manifest de snapshot (shape partagée avec le plugin — ne pas dévier). Le plugin uploade le manifest **gzippé** (`gzencode(json, 6)`) et `manifest_sha256` est le sha256 du fichier gz ; le backend le traite comme un blob opaque partout sauf dans le GC, qui doit le dégunzipper pour le lire :
  ```json
  {
    "version": 1,
    "ts": "2026-07-23T12:00:00Z",
    "wp": { "core": "6.6", "php": "8.2" },
    "files": [{ "p": "wp-content/themes/x/style.css", "h": "<64 hex>", "s": 1234, "m": 1721730000 }],
    "db": [{ "table": "wp_posts", "h": "<sha256 du dump gzip>", "s": 99999, "checksum": "123456" }]
  }
  ```

---

### Task 1: Scaffold du workspace et du Worker (`api/`)

**Files:**
- Create: `pnpm-workspace.yaml`
- Create: `package.json` (racine)
- Create: `api/package.json`
- Create: `api/wrangler.jsonc`
- Create: `api/tsconfig.json`
- Create: `api/vitest.config.ts`
- Create: `api/.oxlintrc.json`
- Create: `api/src/types.ts`
- Create: `api/src/lib/errors.ts`
- Create: `api/src/app.ts`
- Create: `api/src/index.ts`
- Create: `api/test/env.d.ts`
- Create: `api/test/apply-migrations.ts`
- Create: `api/migrations/.gitkeep`
- Test: `api/test/health.test.ts`

**Interfaces:**
- Consumes: rien (première tâche).
- Produces:
  - `api/src/types.ts` : `interface Env { DB: D1Database; JWT_SECRET: string; WASABI_ACCESS_KEY_ID: string; WASABI_SECRET_ACCESS_KEY: string }`, `interface SiteRow { id: string; license_key: string; site_url: string; bucket_id: number; token_version: number; storage_bytes_used: number }`, `interface BucketRow { id: number; name: string; region: string; site_count: number }`, `type AppEnv = { Bindings: Env; Variables: { site: SiteRow } }`.
  - `api/src/lib/errors.ts` : `apiError(c: Context, status: ContentfulStatusCode, code: string, message: string): Response`.
  - `api/src/app.ts` : export default d'une app `Hono<AppEnv>` (les tâches suivantes y montent leurs routes).
  - Commande de test : `pnpm -C api test` ; lint : `pnpm -C api lint` ; typecheck : `pnpm -C api typecheck`.

- [ ] **Step 1: Créer le workspace pnpm et les manifests**

```yaml
# pnpm-workspace.yaml
packages:
  - api
```

```json
// package.json (racine)
{
  "name": "wp-back",
  "private": true,
  "packageManager": "pnpm@10.13.1"
}
```

```json
// api/package.json
{
  "name": "@wp-back/api",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "wrangler dev",
    "deploy": "wrangler deploy",
    "test": "vitest run",
    "test:watch": "vitest",
    "lint": "oxlint src test",
    "format": "oxfmt src test",
    "typecheck": "tsc --noEmit"
  }
}
```

- [ ] **Step 2: Installer les dépendances (via sfw)**

Run:
```bash
cd api
sfw pnpm add hono aws4fetch nanoid
sfw pnpm add -D wrangler typescript @cloudflare/workers-types "vitest@~3.2.0" @cloudflare/vitest-pool-workers oxlint oxfmt
cd ..
```
Expected: `dependencies: + hono …` puis `devDependencies: + wrangler …` sans erreur sfw (aucun package bloqué).

- [ ] **Step 3: Écrire les configs (wrangler, tsconfig, vitest, oxlint)**

```jsonc
// api/wrangler.jsonc
{
  "name": "wp-back-api",
  "main": "src/index.ts",
  "compatibility_date": "2026-07-01",
  "observability": { "enabled": true },
  "triggers": { "crons": ["0 3 * * *"] },
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "wp-back-db",
      "database_id": "local-dev-only",
      "migrations_dir": "migrations"
    }
  ]
}
```

Note : `database_id` n'est utilisé ni par les tests ni par `wrangler dev --local`. Au premier déploiement réel : `pnpm -C api exec wrangler d1 create wp-back-db` (sortie : un bloc `database_id = "<uuid>"`) et coller cet uuid ici.

```jsonc
// api/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "lib": ["ES2022"],
    "types": ["@cloudflare/workers-types", "@cloudflare/vitest-pool-workers"],
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src", "test"]
}
```

```ts
// api/vitest.config.ts
import path from "node:path";
import {
  defineWorkersConfig,
  readD1Migrations,
} from "@cloudflare/vitest-pool-workers/config";

export default defineWorkersConfig(async () => {
  const migrations = await readD1Migrations(path.join(__dirname, "migrations"));
  return {
    test: {
      setupFiles: ["./test/apply-migrations.ts"],
      poolOptions: {
        workers: {
          wrangler: { configPath: "./wrangler.jsonc" },
          miniflare: {
            bindings: {
              TEST_MIGRATIONS: migrations,
              JWT_SECRET: "test-jwt-secret",
              WASABI_ACCESS_KEY_ID: "WASABITESTKEYID00000",
              WASABI_SECRET_ACCESS_KEY: "wasabi-test-secret-key",
            },
          },
        },
      },
    },
  };
});
```

```json
// api/.oxlintrc.json
{
  "categories": { "correctness": "error", "suspicious": "warn" },
  "env": { "worker": true }
}
```

Créer aussi `api/migrations/.gitkeep` (fichier vide — le dossier doit exister pour `readD1Migrations`).

- [ ] **Step 4: Écrire les types partagés, le helper d'erreur et le setup de test**

```ts
// api/src/types.ts
export interface Env {
  DB: D1Database;
  JWT_SECRET: string;
  WASABI_ACCESS_KEY_ID: string;
  WASABI_SECRET_ACCESS_KEY: string;
}

export interface SiteRow {
  id: string;
  license_key: string;
  site_url: string;
  bucket_id: number;
  token_version: number;
  storage_bytes_used: number;
}

export interface BucketRow {
  id: number;
  name: string;
  region: string;
  site_count: number;
}

export type AppEnv = { Bindings: Env; Variables: { site: SiteRow } };
```

```ts
// api/src/lib/errors.ts
import type { Context } from "hono";
import type { ContentfulStatusCode } from "hono/utils/http-status";

export function apiError(
  c: Context,
  status: ContentfulStatusCode,
  code: string,
  message: string,
): Response {
  return c.json({ error: { code, message } }, status);
}
```

```ts
// api/test/env.d.ts
import type { Env } from "../src/types";

declare module "cloudflare:test" {
  interface ProvidedEnv extends Env {
    TEST_MIGRATIONS: D1Migration[];
  }
}
```

```ts
// api/test/apply-migrations.ts
import { applyD1Migrations, env } from "cloudflare:test";

await applyD1Migrations(env.DB, env.TEST_MIGRATIONS);
```

- [ ] **Step 5: Écrire le test santé (échec attendu)**

```ts
// api/test/health.test.ts
import { env } from "cloudflare:test";
import { describe, expect, it } from "vitest";
import app from "../src/app";

describe("health", () => {
  it("GET / returns service identity", async () => {
    const res = await app.request("/", {}, env);
    expect(res.status).toBe(200);
    expect(await res.json()).toEqual({ ok: true, service: "wp-back-api" });
  });
});
```

Run: `pnpm -C api test`
Expected: FAIL — `Cannot find module '../src/app'` (ou équivalent de résolution).

- [ ] **Step 6: Implémenter l'app et le handler**

```ts
// api/src/app.ts
import { Hono } from "hono";
import type { AppEnv } from "./types";
import { apiError } from "./lib/errors";

const app = new Hono<AppEnv>();

app.get("/", (c) => c.json({ ok: true, service: "wp-back-api" }));

app.notFound((c) => apiError(c, 404, "not_found", "Route not found"));
app.onError((err, c) => {
  console.error("unhandled error", err);
  return apiError(c, 500, "internal", "Internal error");
});

export default app;
```

```ts
// api/src/index.ts
import app from "./app";
import type { Env } from "./types";

export default {
  fetch: app.fetch,
} satisfies ExportedHandler<Env>;
```

- [ ] **Step 7: Vérifier tests, lint, typecheck**

Run: `pnpm -C api test && pnpm -C api lint && pnpm -C api typecheck`
Expected: `1 passed`, oxlint `0 errors`, tsc silencieux.

- [ ] **Step 8: Commit**

```bash
git add pnpm-workspace.yaml package.json api pnpm-lock.yaml
git commit -m "feat(api): scaffold Workers API (Hono, D1, vitest-pool-workers, oxc)"
```

---

### Task 2: Schéma D1 (migrations) + seed de dev

**Files:**
- Create: `api/migrations/0001_init.sql`
- Create: `api/seed/dev.sql`
- Test: `api/test/schema.test.ts`

**Interfaces:**
- Consumes: setup de migrations de Task 1 (`TEST_MIGRATIONS` appliquées avant chaque fichier de test).
- Produces: tables `licenses`, `buckets`, `sites`, `blobs`, `snapshots`, `usage_log` (colonnes exactes ci-dessous) utilisées par toutes les tâches suivantes.

- [ ] **Step 1: Écrire le test de schéma (échec attendu)**

```ts
// api/test/schema.test.ts
import { env } from "cloudflare:test";
import { describe, expect, it } from "vitest";

describe("D1 schema", () => {
  it("creates all six tables", async () => {
    const rows = await env.DB.prepare(
      "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '\\_%' ESCAPE '\\' AND name NOT LIKE 'sqlite%' ORDER BY name",
    ).all<{ name: string }>();
    expect(rows.results.map((r) => r.name)).toEqual([
      "blobs",
      "buckets",
      "d1_migrations",
      "licenses",
      "sites",
      "snapshots",
      "usage_log",
    ]);
  });

  it("enforces blob status check constraint", async () => {
    await env.DB.prepare(
      "INSERT INTO licenses (license_key, plan, storage_bytes_limit, retention_days, max_sites, status) VALUES ('L1','test',1000,30,1,'active')",
    ).run();
    await env.DB.prepare(
      "INSERT INTO buckets (id, name, region, site_count) VALUES (1,'b','eu-central-1',0)",
    ).run();
    await env.DB.prepare(
      "INSERT INTO sites (id, license_key, site_url, bucket_id) VALUES ('s1','L1','https://a.tld',1)",
    ).run();
    await expect(
      env.DB.prepare(
        "INSERT INTO blobs (site_id, sha256, size_bytes, status) VALUES ('s1','deadbeef',1,'bogus')",
      ).run(),
    ).rejects.toThrow();
  });
});
```

Run: `pnpm -C api test test/schema.test.ts`
Expected: FAIL — la liste des tables ne contient que `d1_migrations` (aucune migration applicative).

- [ ] **Step 2: Écrire la migration**

```sql
-- api/migrations/0001_init.sql
CREATE TABLE licenses (
  license_key TEXT PRIMARY KEY,
  plan TEXT NOT NULL,
  storage_bytes_limit INTEGER NOT NULL,
  retention_days INTEGER NOT NULL,
  max_sites INTEGER NOT NULL DEFAULT 1,
  status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','suspended','cancelled'))
);

CREATE TABLE buckets (
  id INTEGER PRIMARY KEY,
  name TEXT NOT NULL UNIQUE,
  region TEXT NOT NULL,
  site_count INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE sites (
  id TEXT PRIMARY KEY,
  license_key TEXT NOT NULL REFERENCES licenses(license_key),
  site_url TEXT NOT NULL,
  bucket_id INTEGER NOT NULL REFERENCES buckets(id),
  token_version INTEGER NOT NULL DEFAULT 1,
  storage_bytes_used INTEGER NOT NULL DEFAULT 0,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
  UNIQUE (license_key, site_url)
);

CREATE TABLE blobs (
  site_id TEXT NOT NULL REFERENCES sites(id),
  sha256 TEXT NOT NULL,
  size_bytes INTEGER NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','stored')),
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
  PRIMARY KEY (site_id, sha256)
);

CREATE TABLE snapshots (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  site_id TEXT NOT NULL REFERENCES sites(id),
  ts TEXT NOT NULL,
  manifest_sha256 TEXT NOT NULL,
  stats_json TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'complete' CHECK (status IN ('complete','expired')),
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE INDEX idx_snapshots_site_ts ON snapshots (site_id, ts DESC);

CREATE TABLE usage_log (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  site_id TEXT NOT NULL,
  op TEXT NOT NULL,
  sha256 TEXT,
  size_bytes INTEGER,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE INDEX idx_usage_log_site ON usage_log (site_id, created_at);
```

- [ ] **Step 3: Vérifier que le test passe**

Run: `pnpm -C api test test/schema.test.ts`
Expected: PASS (2 tests).

- [ ] **Step 4: Seed de dev (hors tests)**

```sql
-- api/seed/dev.sql
INSERT INTO licenses (license_key, plan, storage_bytes_limit, retention_days, max_sites, status)
VALUES ('WPB-DEV-0000-0000', 'dev', 10737418240, 30, 5, 'active');

INSERT INTO buckets (id, name, region, site_count)
VALUES (1, 'wpback-pool-001', 'eu-central-1', 0);
```

Run (usage local documenté, pas en CI) :
```bash
pnpm -C api exec wrangler d1 migrations apply wp-back-db --local
pnpm -C api exec wrangler d1 execute wp-back-db --local --file seed/dev.sql
```
Expected: `1 migration applied`, puis `2 commands executed`.

- [ ] **Step 5: Commit**

```bash
git add api/migrations api/seed api/test/schema.test.ts
git commit -m "feat(api): D1 schema migrations and dev seed"
```

---

### Task 3: Module Wasabi (`src/lib/wasabi.ts`)

**Files:**
- Create: `api/src/lib/wasabi.ts`
- Test: `api/test/wasabi.test.ts`

**Interfaces:**
- Consumes: rien (module pur, aws4fetch).
- Produces (signatures exactes, consommées par Tasks 6–11) :
  - `blobKey(siteId: string, sha256: string): string` → `s/{siteId}/blobs/{sha256}`
  - `interface WasabiCreds { accessKeyId: string; secretAccessKey: string }`
  - `interface BucketLocation { name: string; region: string }`
  - `interface PresignedUrl { url: string; expiresAt: string }` (`expiresAt` ISO 8601)
  - `presignPut(creds: WasabiCreds, bucket: BucketLocation, key: string): Promise<PresignedUrl>`
  - `presignGet(creds: WasabiCreds, bucket: BucketLocation, key: string): Promise<PresignedUrl>`
  - `headObject(creds: WasabiCreds, bucket: BucketLocation, key: string): Promise<number | null>` (taille en octets, `null` si 404)
  - `getObjectJson<T>(creds: WasabiCreds, bucket: BucketLocation, key: string): Promise<T>` — dégunzippe automatiquement si l'objet commence par le magic gzip `1f 8b` (les manifests sont uploadés gzippés par le plugin), sinon parse le JSON brut
  - `deleteObject(creds: WasabiCreds, bucket: BucketLocation, key: string): Promise<void>`
  - `PRESIGN_TTL_SECONDS = 900`

- [ ] **Step 1: Écrire les tests (échec attendu)**

```ts
// api/test/wasabi.test.ts
import { describe, expect, it } from "vitest";
import {
  blobKey,
  PRESIGN_TTL_SECONDS,
  presignGet,
  presignPut,
} from "../src/lib/wasabi";

const creds = {
  accessKeyId: "WASABITESTKEYID00000",
  secretAccessKey: "wasabi-test-secret-key",
};
const bucket = { name: "wpback-test-001", region: "eu-central-1" };
const SHA = "a".repeat(64);

describe("blobKey", () => {
  it("builds the canonical per-site key", () => {
    expect(blobKey("site123", SHA)).toBe(`s/site123/blobs/${SHA}`);
  });
});

describe("presign", () => {
  it("signs a PUT URL on the wasabi endpoint with 900s expiry", async () => {
    const { url, expiresAt } = await presignPut(creds, bucket, blobKey("s1", SHA));
    const u = new URL(url);
    expect(u.hostname).toBe("s3.eu-central-1.wasabisys.com");
    expect(u.pathname).toBe(`/wpback-test-001/s/s1/blobs/${SHA}`);
    expect(u.searchParams.get("X-Amz-Expires")).toBe(String(PRESIGN_TTL_SECONDS));
    expect(u.searchParams.get("X-Amz-Signature")).toMatch(/^[a-f0-9]{64}$/);
    expect(Date.parse(expiresAt)).toBeGreaterThan(Date.now());
  });

  it("GET and PUT signatures differ for the same key", async () => {
    const put = await presignPut(creds, bucket, blobKey("s1", SHA));
    const get = await presignGet(creds, bucket, blobKey("s1", SHA));
    expect(new URL(put.url).searchParams.get("X-Amz-Signature")).not.toBe(
      new URL(get.url).searchParams.get("X-Amz-Signature"),
    );
  });
});
```

Run: `pnpm -C api test test/wasabi.test.ts`
Expected: FAIL — `Cannot find module '../src/lib/wasabi'`.

- [ ] **Step 2: Implémenter le module**

```ts
// api/src/lib/wasabi.ts
import { AwsClient } from "aws4fetch";

export const PRESIGN_TTL_SECONDS = 900;

export interface WasabiCreds {
  accessKeyId: string;
  secretAccessKey: string;
}

export interface BucketLocation {
  name: string;
  region: string;
}

export interface PresignedUrl {
  url: string;
  expiresAt: string;
}

export function blobKey(siteId: string, sha256: string): string {
  return `s/${siteId}/blobs/${sha256}`;
}

function objectUrl(bucket: BucketLocation, key: string): string {
  return `https://s3.${bucket.region}.wasabisys.com/${bucket.name}/${key}`;
}

function client(creds: WasabiCreds, region: string): AwsClient {
  return new AwsClient({
    accessKeyId: creds.accessKeyId,
    secretAccessKey: creds.secretAccessKey,
    service: "s3",
    region,
  });
}

async function presign(
  creds: WasabiCreds,
  bucket: BucketLocation,
  key: string,
  method: "PUT" | "GET",
): Promise<PresignedUrl> {
  const url = new URL(objectUrl(bucket, key));
  url.searchParams.set("X-Amz-Expires", String(PRESIGN_TTL_SECONDS));
  const signed = await client(creds, bucket.region).sign(
    new Request(url, { method }),
    { aws: { signQuery: true } },
  );
  return {
    url: signed.url,
    expiresAt: new Date(Date.now() + PRESIGN_TTL_SECONDS * 1000).toISOString(),
  };
}

export function presignPut(
  creds: WasabiCreds,
  bucket: BucketLocation,
  key: string,
): Promise<PresignedUrl> {
  return presign(creds, bucket, key, "PUT");
}

export function presignGet(
  creds: WasabiCreds,
  bucket: BucketLocation,
  key: string,
): Promise<PresignedUrl> {
  return presign(creds, bucket, key, "GET");
}

export async function headObject(
  creds: WasabiCreds,
  bucket: BucketLocation,
  key: string,
): Promise<number | null> {
  const res = await client(creds, bucket.region).fetch(objectUrl(bucket, key), {
    method: "HEAD",
  });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`HEAD ${key} failed with ${res.status}`);
  return Number(res.headers.get("content-length") ?? "0");
}

export async function getObjectJson<T>(
  creds: WasabiCreds,
  bucket: BucketLocation,
  key: string,
): Promise<T> {
  const res = await client(creds, bucket.region).fetch(objectUrl(bucket, key));
  if (!res.ok) throw new Error(`GET ${key} failed with ${res.status}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
    const stream = new Response(bytes).body!.pipeThrough(
      new DecompressionStream("gzip"),
    );
    return (await new Response(stream).json()) as T;
  }
  return JSON.parse(new TextDecoder().decode(bytes)) as T;
}

export async function deleteObject(
  creds: WasabiCreds,
  bucket: BucketLocation,
  key: string,
): Promise<void> {
  const res = await client(creds, bucket.region).fetch(objectUrl(bucket, key), {
    method: "DELETE",
  });
  if (!res.ok && res.status !== 404) {
    throw new Error(`DELETE ${key} failed with ${res.status}`);
  }
}
```

- [ ] **Step 3: Vérifier**

Run: `pnpm -C api test test/wasabi.test.ts`
Expected: PASS (3 tests).

- [ ] **Step 4: Commit**

```bash
git add api/src/lib/wasabi.ts api/test/wasabi.test.ts
git commit -m "feat(api): Wasabi presigning module (aws4fetch, canonical blob keys)"
```

---

### Task 4: `POST /v1/sites/activate`

**Files:**
- Create: `api/src/routes/sites.ts`
- Modify: `api/src/app.ts`
- Create: `api/test/helpers.ts`
- Test: `api/test/activate.test.ts`

**Interfaces:**
- Consumes: schéma D1 (Task 2), `apiError` (Task 1).
- Produces:
  - Route `POST /v1/sites/activate` — contrat : body `{license_key, site_url}` → 201 `{site_id, site_token, plan: {storage_bytes_limit, retention_days}}` ; réactivation même couple → même `site_id`, `token_version` incrémenté ; 403 `invalid_license` / `site_limit_reached` ; 400 `invalid_request`.
  - `api/test/helpers.ts` : `TEST_LICENSE: string`, `seedLicenseAndBucket(overrides?: { storage_bytes_limit?: number; retention_days?: number }): Promise<void>`, `activateTestSite(siteUrl?: string): Promise<{ site_id: string; site_token: string }>`, `authHeaders(token: string): Record<string, string>` — utilisés par toutes les tâches suivantes.
  - Constante `TOKEN_TTL_SECONDS = 30 * 24 * 3600` (dans `sites.ts`).

- [ ] **Step 1: Écrire helpers + tests (échec attendu)**

```ts
// api/test/helpers.ts
import { env } from "cloudflare:test";
import app from "../src/app";

export const TEST_LICENSE = "WPB-TEST-1111-2222";

export async function seedLicenseAndBucket(overrides?: {
  storage_bytes_limit?: number;
  retention_days?: number;
}): Promise<void> {
  await env.DB.prepare(
    "INSERT INTO licenses (license_key, plan, storage_bytes_limit, retention_days, max_sites, status) VALUES (?, 'test', ?, ?, 3, 'active')",
  )
    .bind(
      TEST_LICENSE,
      overrides?.storage_bytes_limit ?? 1_000_000,
      overrides?.retention_days ?? 30,
    )
    .run();
  await env.DB.prepare(
    "INSERT INTO buckets (id, name, region, site_count) VALUES (1, 'wpback-test-001', 'eu-central-1', 0)",
  ).run();
}

export async function activateTestSite(
  siteUrl = "https://example.com",
): Promise<{ site_id: string; site_token: string }> {
  const res = await app.request(
    "/v1/sites/activate",
    {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ license_key: TEST_LICENSE, site_url: siteUrl }),
    },
    env,
  );
  if (res.status !== 201) throw new Error(`activate failed: ${res.status}`);
  return (await res.json()) as { site_id: string; site_token: string };
}

export function authHeaders(token: string): Record<string, string> {
  return { "content-type": "application/json", authorization: `Bearer ${token}` };
}
```

```ts
// api/test/activate.test.ts
import { env } from "cloudflare:test";
import { beforeEach, describe, expect, it } from "vitest";
import app from "../src/app";
import { seedLicenseAndBucket, TEST_LICENSE } from "./helpers";

function post(body: unknown) {
  return app.request(
    "/v1/sites/activate",
    {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(body),
    },
    env,
  );
}

describe("POST /v1/sites/activate", () => {
  beforeEach(seedLicenseAndBucket);

  it("creates a site and returns a token + plan", async () => {
    const res = await post({ license_key: TEST_LICENSE, site_url: "https://a.tld" });
    expect(res.status).toBe(201);
    const body = (await res.json()) as {
      site_id: string;
      site_token: string;
      plan: { storage_bytes_limit: number; retention_days: number };
    };
    expect(body.site_id.length).toBeGreaterThan(10);
    expect(body.site_token.split(".").length).toBe(3);
    expect(body.plan).toEqual({ storage_bytes_limit: 1_000_000, retention_days: 30 });
    const bucket = await env.DB.prepare("SELECT site_count FROM buckets WHERE id = 1")
      .first<{ site_count: number }>();
    expect(bucket?.site_count).toBe(1);
  });

  it("re-activation keeps site_id and rotates token_version", async () => {
    const first = (await (await post({ license_key: TEST_LICENSE, site_url: "https://a.tld" })).json()) as { site_id: string };
    const res = await post({ license_key: TEST_LICENSE, site_url: "https://a.tld" });
    expect(res.status).toBe(201);
    const second = (await res.json()) as { site_id: string };
    expect(second.site_id).toBe(first.site_id);
    const row = await env.DB.prepare("SELECT token_version FROM sites WHERE id = ?")
      .bind(first.site_id)
      .first<{ token_version: number }>();
    expect(row?.token_version).toBe(2);
  });

  it("rejects an unknown license with 403", async () => {
    const res = await post({ license_key: "WPB-NOPE", site_url: "https://a.tld" });
    expect(res.status).toBe(403);
    const body = (await res.json()) as { error: { code: string } };
    expect(body.error.code).toBe("invalid_license");
  });

  it("rejects when max_sites is reached", async () => {
    await post({ license_key: TEST_LICENSE, site_url: "https://a1.tld" });
    await post({ license_key: TEST_LICENSE, site_url: "https://a2.tld" });
    await post({ license_key: TEST_LICENSE, site_url: "https://a3.tld" });
    const res = await post({ license_key: TEST_LICENSE, site_url: "https://a4.tld" });
    expect(res.status).toBe(403);
    const body = (await res.json()) as { error: { code: string } };
    expect(body.error.code).toBe("site_limit_reached");
  });

  it("rejects malformed body and bad URLs with 400", async () => {
    expect((await post({ license_key: TEST_LICENSE })).status).toBe(400);
    expect(
      (await post({ license_key: TEST_LICENSE, site_url: "not-a-url" })).status,
    ).toBe(400);
  });
});
```

Run: `pnpm -C api test test/activate.test.ts`
Expected: FAIL — 404 sur toutes les requêtes (route absente).

- [ ] **Step 2: Implémenter la route**

```ts
// api/src/routes/sites.ts
import { Hono } from "hono";
import { sign } from "hono/jwt";
import { nanoid } from "nanoid";
import { apiError } from "../lib/errors";
import type { AppEnv } from "../types";

export const TOKEN_TTL_SECONDS = 30 * 24 * 3600;

interface LicenseRow {
  license_key: string;
  storage_bytes_limit: number;
  retention_days: number;
  max_sites: number;
}

export const sites = new Hono<AppEnv>();

sites.post("/activate", async (c) => {
  const body = (await c.req.json().catch(() => null)) as
    | { license_key?: unknown; site_url?: unknown }
    | null;
  if (!body || typeof body.license_key !== "string" || typeof body.site_url !== "string") {
    return apiError(c, 400, "invalid_request", "license_key and site_url are required");
  }

  let siteUrl: string;
  try {
    const u = new URL(body.site_url);
    if (u.protocol !== "https:" && u.protocol !== "http:") throw new Error("protocol");
    siteUrl = u.origin + u.pathname.replace(/\/+$/, "");
  } catch {
    return apiError(c, 400, "invalid_request", "site_url must be a valid http(s) URL");
  }

  const license = await c.env.DB.prepare(
    "SELECT license_key, storage_bytes_limit, retention_days, max_sites FROM licenses WHERE license_key = ? AND status = 'active'",
  )
    .bind(body.license_key)
    .first<LicenseRow>();
  if (!license) return apiError(c, 403, "invalid_license", "License not found or inactive");

  const existing = await c.env.DB.prepare(
    "SELECT id, token_version FROM sites WHERE license_key = ? AND site_url = ?",
  )
    .bind(license.license_key, siteUrl)
    .first<{ id: string; token_version: number }>();

  let siteId: string;
  let tokenVersion: number;

  if (existing) {
    siteId = existing.id;
    tokenVersion = existing.token_version + 1;
    await c.env.DB.prepare("UPDATE sites SET token_version = ? WHERE id = ?")
      .bind(tokenVersion, siteId)
      .run();
  } else {
    const count = await c.env.DB.prepare(
      "SELECT COUNT(*) AS n FROM sites WHERE license_key = ?",
    )
      .bind(license.license_key)
      .first<{ n: number }>();
    if ((count?.n ?? 0) >= license.max_sites) {
      return apiError(c, 403, "site_limit_reached", "License site limit reached");
    }
    const bucket = await c.env.DB.prepare(
      "SELECT id FROM buckets ORDER BY site_count ASC LIMIT 1",
    ).first<{ id: number }>();
    if (!bucket) return apiError(c, 500, "no_bucket", "No storage bucket available");

    siteId = nanoid();
    tokenVersion = 1;
    await c.env.DB.batch([
      c.env.DB.prepare(
        "INSERT INTO sites (id, license_key, site_url, bucket_id, token_version, storage_bytes_used) VALUES (?, ?, ?, ?, 1, 0)",
      ).bind(siteId, license.license_key, siteUrl, bucket.id),
      c.env.DB.prepare("UPDATE buckets SET site_count = site_count + 1 WHERE id = ?").bind(
        bucket.id,
      ),
    ]);
  }

  const token = await sign(
    {
      sub: siteId,
      ver: tokenVersion,
      exp: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS,
    },
    c.env.JWT_SECRET,
  );

  return c.json(
    {
      site_id: siteId,
      site_token: token,
      plan: {
        storage_bytes_limit: license.storage_bytes_limit,
        retention_days: license.retention_days,
      },
    },
    201,
  );
});
```

Dans `api/src/app.ts`, ajouter après la route `GET /` :

```ts
import { sites } from "./routes/sites";

app.route("/v1/sites", sites);
```

- [ ] **Step 3: Vérifier**

Run: `pnpm -C api test`
Expected: PASS (health + schema + wasabi + 5 tests activate).

- [ ] **Step 4: Commit**

```bash
git add api/src/routes/sites.ts api/src/app.ts api/test/helpers.ts api/test/activate.test.ts
git commit -m "feat(api): site activation with license checks, bucket assignment and JWT issuance"
```

---

### Task 5: Middleware d'auth JWT (`siteAuth`)

**Files:**
- Create: `api/src/middleware/auth.ts`
- Test: `api/test/auth.test.ts`

**Interfaces:**
- Consumes: `AppEnv`, `SiteRow` (Task 1), `apiError` (Task 1), table `sites` (Task 2), token émis par Task 4.
- Produces: `siteAuth: MiddlewareHandler<AppEnv>` — vérifie `Authorization: Bearer`, décode HS256, contrôle `payload.ver === sites.token_version`, pose `c.set("site", SiteRow)` ; sinon 401 `unauthorized`. Utilisé par Tasks 6–10, 12.

- [ ] **Step 1: Écrire les tests (échec attendu)**

```ts
// api/test/auth.test.ts
import { env } from "cloudflare:test";
import { Hono } from "hono";
import { beforeEach, describe, expect, it } from "vitest";
import { siteAuth } from "../src/middleware/auth";
import type { AppEnv } from "../src/types";
import { activateTestSite, seedLicenseAndBucket } from "./helpers";

function protectedApp() {
  const a = new Hono<AppEnv>();
  a.use("*", siteAuth);
  a.get("/whoami", (c) => c.json({ site_id: c.get("site").id }));
  return a;
}

describe("siteAuth middleware", () => {
  beforeEach(seedLicenseAndBucket);

  it("rejects missing and malformed tokens with 401", async () => {
    const a = protectedApp();
    expect((await a.request("/whoami", {}, env)).status).toBe(401);
    const bad = await a.request(
      "/whoami",
      { headers: { authorization: "Bearer not.a.jwt" } },
      env,
    );
    expect(bad.status).toBe(401);
  });

  it("accepts a fresh token and exposes the site", async () => {
    const { site_id, site_token } = await activateTestSite();
    const res = await protectedApp().request(
      "/whoami",
      { headers: { authorization: `Bearer ${site_token}` } },
      env,
    );
    expect(res.status).toBe(200);
    expect(await res.json()).toEqual({ site_id });
  });

  it("rejects a token from a previous token_version after re-activation", async () => {
    const first = await activateTestSite();
    await activateTestSite(); // rotation: token_version -> 2
    const res = await protectedApp().request(
      "/whoami",
      { headers: { authorization: `Bearer ${first.site_token}` } },
      env,
    );
    expect(res.status).toBe(401);
  });
});
```

Run: `pnpm -C api test test/auth.test.ts`
Expected: FAIL — `Cannot find module '../src/middleware/auth'`.

- [ ] **Step 2: Implémenter le middleware**

```ts
// api/src/middleware/auth.ts
import { createMiddleware } from "hono/factory";
import { verify } from "hono/jwt";
import { apiError } from "../lib/errors";
import type { AppEnv, SiteRow } from "../types";

export const siteAuth = createMiddleware<AppEnv>(async (c, next) => {
  const header = c.req.header("authorization");
  if (!header || !header.startsWith("Bearer ")) {
    return apiError(c, 401, "unauthorized", "Missing bearer token");
  }

  let payload: { sub?: unknown; ver?: unknown };
  try {
    payload = (await verify(header.slice(7), c.env.JWT_SECRET)) as {
      sub?: unknown;
      ver?: unknown;
    };
  } catch {
    return apiError(c, 401, "unauthorized", "Invalid or expired token");
  }
  if (typeof payload.sub !== "string" || typeof payload.ver !== "number") {
    return apiError(c, 401, "unauthorized", "Invalid token claims");
  }

  const site = await c.env.DB.prepare(
    "SELECT id, license_key, site_url, bucket_id, token_version, storage_bytes_used FROM sites WHERE id = ?",
  )
    .bind(payload.sub)
    .first<SiteRow>();
  if (!site || site.token_version !== payload.ver) {
    return apiError(c, 401, "unauthorized", "Token has been rotated");
  }

  c.set("site", site);
  await next();
});
```

- [ ] **Step 3: Vérifier**

Run: `pnpm -C api test test/auth.test.ts`
Expected: PASS (3 tests).

- [ ] **Step 4: Commit**

```bash
git add api/src/middleware/auth.ts api/test/auth.test.ts
git commit -m "feat(api): site JWT auth middleware with token rotation check"
```

---

### Task 6: `POST /v1/storage/blobs/sign-upload`

**Files:**
- Create: `api/src/routes/storage.ts`
- Create: `api/src/lib/usage-log.ts`
- Modify: `api/src/app.ts`
- Test: `api/test/sign-upload.test.ts`

**Interfaces:**
- Consumes: `siteAuth` (Task 5), `presignPut`/`blobKey`/`PRESIGN_TTL_SECONDS` (Task 3), helpers de test (Task 4).
- Produces:
  - Route `POST /v1/storage/blobs/sign-upload` — body `{sha256, size_bytes}` → 200 `{status:"upload", upload_url, expires_at}` ou `{status:"exists"}` ; 400 `invalid_sha256`/`invalid_size`/`invalid_request` ; 402 `quota_exceeded`.
  - `logUsage(db: D1Database, siteId: string, op: string, sha256: string | null, sizeBytes: number | null): Promise<void>` (`src/lib/usage-log.ts`).
  - Helpers internes de `storage.ts` réutilisés par Tasks 7–8 : `SHA256_RE = /^[a-f0-9]{64}$/`, `MAX_OBJECT_BYTES = 5_368_709_120`, `getBucket(c): Promise<BucketLocation>`, `wasabiCreds(env: Env): WasabiCreds`.

- [ ] **Step 1: Écrire les tests (échec attendu)**

```ts
// api/test/sign-upload.test.ts
import { env } from "cloudflare:test";
import { beforeEach, describe, expect, it } from "vitest";
import app from "../src/app";
import { activateTestSite, authHeaders, seedLicenseAndBucket } from "./helpers";

const SHA = "b".repeat(64);

function signUpload(token: string, body: unknown) {
  return app.request(
    "/v1/storage/blobs/sign-upload",
    { method: "POST", headers: authHeaders(token), body: JSON.stringify(body) },
    env,
  );
}

describe("POST /v1/storage/blobs/sign-upload", () => {
  beforeEach(seedLicenseAndBucket);

  it("requires auth", async () => {
    const res = await app.request(
      "/v1/storage/blobs/sign-upload",
      { method: "POST", body: JSON.stringify({ sha256: SHA, size_bytes: 10 }) },
      env,
    );
    expect(res.status).toBe(401);
  });

  it("returns a presigned PUT URL and records a pending blob + usage log", async () => {
    const { site_id, site_token } = await activateTestSite();
    const res = await signUpload(site_token, { sha256: SHA, size_bytes: 1234 });
    expect(res.status).toBe(200);
    const body = (await res.json()) as {
      status: string;
      upload_url: string;
      expires_at: string;
    };
    expect(body.status).toBe("upload");
    const u = new URL(body.upload_url);
    expect(u.pathname).toBe(`/wpback-test-001/s/${site_id}/blobs/${SHA}`);
    expect(u.searchParams.get("X-Amz-Expires")).toBe("900");

    const blob = await env.DB.prepare(
      "SELECT status, size_bytes FROM blobs WHERE site_id = ? AND sha256 = ?",
    )
      .bind(site_id, SHA)
      .first<{ status: string; size_bytes: number }>();
    expect(blob).toEqual({ status: "pending", size_bytes: 1234 });

    const log = await env.DB.prepare(
      "SELECT op, size_bytes FROM usage_log WHERE site_id = ?",
    )
      .bind(site_id)
      .first<{ op: string; size_bytes: number }>();
    expect(log).toEqual({ op: "sign_upload", size_bytes: 1234 });
  });

  it("returns exists for an already stored blob", async () => {
    const { site_id, site_token } = await activateTestSite();
    await env.DB.prepare(
      "INSERT INTO blobs (site_id, sha256, size_bytes, status) VALUES (?, ?, 10, 'stored')",
    )
      .bind(site_id, SHA)
      .run();
    const res = await signUpload(site_token, { sha256: SHA, size_bytes: 10 });
    expect(res.status).toBe(200);
    expect(await res.json()).toEqual({ status: "exists" });
  });

  it("rejects invalid sha256 and sizes with 400", async () => {
    const { site_token } = await activateTestSite();
    expect(
      (await signUpload(site_token, { sha256: "XYZ", size_bytes: 10 })).status,
    ).toBe(400);
    expect(
      (await signUpload(site_token, { sha256: SHA, size_bytes: 0 })).status,
    ).toBe(400);
    expect(
      (await signUpload(site_token, { sha256: SHA, size_bytes: 6_000_000_000 })).status,
    ).toBe(400);
  });

  it("rejects over-quota uploads with 402", async () => {
    const { site_token } = await activateTestSite(); // limit 1_000_000
    const res = await signUpload(site_token, { sha256: SHA, size_bytes: 1_000_001 });
    expect(res.status).toBe(402);
    const body = (await res.json()) as { error: { code: string } };
    expect(body.error.code).toBe("quota_exceeded");
  });
});
```

Run: `pnpm -C api test test/sign-upload.test.ts`
Expected: FAIL — 404 (route absente).

- [ ] **Step 2: Implémenter usage-log + la route**

```ts
// api/src/lib/usage-log.ts
export async function logUsage(
  db: D1Database,
  siteId: string,
  op: string,
  sha256: string | null,
  sizeBytes: number | null,
): Promise<void> {
  await db
    .prepare("INSERT INTO usage_log (site_id, op, sha256, size_bytes) VALUES (?, ?, ?, ?)")
    .bind(siteId, op, sha256, sizeBytes)
    .run();
}
```

```ts
// api/src/routes/storage.ts
import { Hono } from "hono";
import type { Context } from "hono";
import { apiError } from "../lib/errors";
import { logUsage } from "../lib/usage-log";
import {
  blobKey,
  presignPut,
  type BucketLocation,
  type WasabiCreds,
} from "../lib/wasabi";
import type { AppEnv, BucketRow, Env } from "../types";

export const SHA256_RE = /^[a-f0-9]{64}$/;
export const MAX_OBJECT_BYTES = 5_368_709_120;

export function wasabiCreds(env: Env): WasabiCreds {
  return {
    accessKeyId: env.WASABI_ACCESS_KEY_ID,
    secretAccessKey: env.WASABI_SECRET_ACCESS_KEY,
  };
}

export async function getBucket(c: Context<AppEnv>): Promise<BucketLocation> {
  const site = c.get("site");
  const bucket = await c.env.DB.prepare(
    "SELECT id, name, region, site_count FROM buckets WHERE id = ?",
  )
    .bind(site.bucket_id)
    .first<BucketRow>();
  if (!bucket) throw new Error(`bucket ${site.bucket_id} missing for site ${site.id}`);
  return { name: bucket.name, region: bucket.region };
}

export const storage = new Hono<AppEnv>();

storage.post("/blobs/sign-upload", async (c) => {
  const site = c.get("site");
  const body = (await c.req.json().catch(() => null)) as
    | { sha256?: unknown; size_bytes?: unknown }
    | null;
  if (!body || typeof body.sha256 !== "string" || typeof body.size_bytes !== "number") {
    return apiError(c, 400, "invalid_request", "sha256 and size_bytes are required");
  }
  if (!SHA256_RE.test(body.sha256)) {
    return apiError(c, 400, "invalid_sha256", "sha256 must be 64 lowercase hex chars");
  }
  if (
    !Number.isInteger(body.size_bytes) ||
    body.size_bytes <= 0 ||
    body.size_bytes > MAX_OBJECT_BYTES
  ) {
    return apiError(c, 400, "invalid_size", "size_bytes out of accepted range");
  }

  const existing = await c.env.DB.prepare(
    "SELECT status FROM blobs WHERE site_id = ? AND sha256 = ?",
  )
    .bind(site.id, body.sha256)
    .first<{ status: string }>();
  if (existing?.status === "stored") return c.json({ status: "exists" });

  const license = await c.env.DB.prepare(
    "SELECT storage_bytes_limit FROM licenses WHERE license_key = ?",
  )
    .bind(site.license_key)
    .first<{ storage_bytes_limit: number }>();
  if (!license) return apiError(c, 403, "invalid_license", "License not found");
  if (site.storage_bytes_used + body.size_bytes > license.storage_bytes_limit) {
    return apiError(c, 402, "quota_exceeded", "Storage quota exceeded");
  }

  await c.env.DB.prepare(
    "INSERT INTO blobs (site_id, sha256, size_bytes, status) VALUES (?, ?, ?, 'pending') ON CONFLICT(site_id, sha256) DO UPDATE SET size_bytes = excluded.size_bytes",
  )
    .bind(site.id, body.sha256, body.size_bytes)
    .run();
  await logUsage(c.env.DB, site.id, "sign_upload", body.sha256, body.size_bytes);

  const bucket = await getBucket(c);
  const presigned = await presignPut(
    wasabiCreds(c.env),
    bucket,
    blobKey(site.id, body.sha256),
  );
  return c.json({
    status: "upload",
    upload_url: presigned.url,
    expires_at: presigned.expiresAt,
  });
});
```

Dans `api/src/app.ts`, ajouter après `app.route("/v1/sites", sites);` :

```ts
import { siteAuth } from "./middleware/auth";
import { storage } from "./routes/storage";

app.use("/v1/storage/*", siteAuth);
app.route("/v1/storage", storage);
```

- [ ] **Step 3: Vérifier**

Run: `pnpm -C api test test/sign-upload.test.ts`
Expected: PASS (5 tests).

- [ ] **Step 4: Commit**

```bash
git add api/src/routes/storage.ts api/src/lib/usage-log.ts api/src/app.ts api/test/sign-upload.test.ts
git commit -m "feat(api): blob sign-upload with quota enforcement, dedup and usage log"
```

---

### Task 7: `POST /v1/storage/blobs/confirm` (réconciliation HEAD)

**Files:**
- Modify: `api/src/routes/storage.ts`
- Test: `api/test/confirm.test.ts`

**Interfaces:**
- Consumes: `headObject`/`deleteObject`/`blobKey` (Task 3), `getBucket`/`wasabiCreds`/`SHA256_RE` (Task 6), `fetchMock` de `cloudflare:test`.
- Produces: Route `POST /v1/storage/blobs/confirm` — body `{sha256}` → 200 `{status:"stored"}` (idempotent si déjà stored) ; 404 blob inconnu du site ; 400 `not_uploaded` si absent de Wasabi ; 409 `size_mismatch` (objet et ligne blob supprimés) ; incrémente `sites.storage_bytes_used` de la taille réelle.

- [ ] **Step 1: Écrire les tests avec fetchMock (échec attendu)**

```ts
// api/test/confirm.test.ts
import { env, fetchMock } from "cloudflare:test";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import app from "../src/app";
import { activateTestSite, authHeaders, seedLicenseAndBucket } from "./helpers";

const SHA = "c".repeat(64);
const S3_ORIGIN = "https://s3.eu-central-1.wasabisys.com";

beforeAll(() => {
  fetchMock.activate();
  fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());

function confirm(token: string, sha256: string) {
  return app.request(
    "/v1/storage/blobs/confirm",
    { method: "POST", headers: authHeaders(token), body: JSON.stringify({ sha256 }) },
    env,
  );
}

async function seedPendingBlob(siteId: string, declaredSize: number) {
  await env.DB.prepare(
    "INSERT INTO blobs (site_id, sha256, size_bytes, status) VALUES (?, ?, ?, 'pending')",
  )
    .bind(siteId, SHA, declaredSize)
    .run();
}

describe("POST /v1/storage/blobs/confirm", () => {
  beforeEach(seedLicenseAndBucket);

  it("marks the blob stored and adds the real size to usage", async () => {
    const { site_id, site_token } = await activateTestSite();
    await seedPendingBlob(site_id, 1234);
    fetchMock
      .get(S3_ORIGIN)
      .intercept({ path: `/wpback-test-001/s/${site_id}/blobs/${SHA}`, method: "HEAD" })
      .reply(200, "", { headers: { "content-length": "1234" } });

    const res = await confirm(site_token, SHA);
    expect(res.status).toBe(200);
    expect(await res.json()).toEqual({ status: "stored" });

    const site = await env.DB.prepare("SELECT storage_bytes_used FROM sites WHERE id = ?")
      .bind(site_id)
      .first<{ storage_bytes_used: number }>();
    expect(site?.storage_bytes_used).toBe(1234);
  });

  it("is idempotent for an already stored blob (no second HEAD)", async () => {
    const { site_id, site_token } = await activateTestSite();
    await env.DB.prepare(
      "INSERT INTO blobs (site_id, sha256, size_bytes, status) VALUES (?, ?, 10, 'stored')",
    )
      .bind(site_id, SHA)
      .run();
    const res = await confirm(site_token, SHA);
    expect(res.status).toBe(200);
    expect(await res.json()).toEqual({ status: "stored" });
  });

  it("returns 400 not_uploaded when the object is missing in Wasabi", async () => {
    const { site_id, site_token } = await activateTestSite();
    await seedPendingBlob(site_id, 1234);
    fetchMock
      .get(S3_ORIGIN)
      .intercept({ path: `/wpback-test-001/s/${site_id}/blobs/${SHA}`, method: "HEAD" })
      .reply(404, "");
    const res = await confirm(site_token, SHA);
    expect(res.status).toBe(400);
    const body = (await res.json()) as { error: { code: string } };
    expect(body.error.code).toBe("not_uploaded");
  });

  it("deletes the object and the row on size mismatch (409)", async () => {
    const { site_id, site_token } = await activateTestSite();
    await seedPendingBlob(site_id, 1234);
    const path = `/wpback-test-001/s/${site_id}/blobs/${SHA}`;
    fetchMock
      .get(S3_ORIGIN)
      .intercept({ path, method: "HEAD" })
      .reply(200, "", { headers: { "content-length": "999" } });
    fetchMock.get(S3_ORIGIN).intercept({ path, method: "DELETE" }).reply(204, "");

    const res = await confirm(site_token, SHA);
    expect(res.status).toBe(409);
    const blob = await env.DB.prepare(
      "SELECT sha256 FROM blobs WHERE site_id = ? AND sha256 = ?",
    )
      .bind(site_id, SHA)
      .first();
    expect(blob).toBeNull();
  });

  it("returns 404 for a blob the site never declared", async () => {
    const { site_token } = await activateTestSite();
    const res = await confirm(site_token, SHA);
    expect(res.status).toBe(404);
  });
});
```

Run: `pnpm -C api test test/confirm.test.ts`
Expected: FAIL — 404 (route absente).

- [ ] **Step 2: Implémenter la route (ajout dans `storage.ts`)**

Ajouter dans `api/src/routes/storage.ts` (imports : ajouter `headObject`, `deleteObject` à l'import de `../lib/wasabi`) :

```ts
storage.post("/blobs/confirm", async (c) => {
  const site = c.get("site");
  const body = (await c.req.json().catch(() => null)) as { sha256?: unknown } | null;
  if (!body || typeof body.sha256 !== "string" || !SHA256_RE.test(body.sha256)) {
    return apiError(c, 400, "invalid_request", "A valid sha256 is required");
  }

  const blob = await c.env.DB.prepare(
    "SELECT status, size_bytes FROM blobs WHERE site_id = ? AND sha256 = ?",
  )
    .bind(site.id, body.sha256)
    .first<{ status: string; size_bytes: number }>();
  if (!blob) return apiError(c, 404, "not_found", "Unknown blob for this site");
  if (blob.status === "stored") return c.json({ status: "stored" });

  const bucket = await getBucket(c);
  const key = blobKey(site.id, body.sha256);
  const realSize = await headObject(wasabiCreds(c.env), bucket, key);

  if (realSize === null) {
    return apiError(c, 400, "not_uploaded", "Object not found in storage");
  }
  if (realSize !== blob.size_bytes) {
    await deleteObject(wasabiCreds(c.env), bucket, key);
    await c.env.DB.prepare("DELETE FROM blobs WHERE site_id = ? AND sha256 = ?")
      .bind(site.id, body.sha256)
      .run();
    return apiError(c, 409, "size_mismatch", "Uploaded size does not match declaration");
  }

  await c.env.DB.batch([
    c.env.DB.prepare(
      "UPDATE blobs SET status = 'stored' WHERE site_id = ? AND sha256 = ?",
    ).bind(site.id, body.sha256),
    c.env.DB.prepare(
      "UPDATE sites SET storage_bytes_used = storage_bytes_used + ? WHERE id = ?",
    ).bind(realSize, site.id),
  ]);
  await logUsage(c.env.DB, site.id, "confirm", body.sha256, realSize);
  return c.json({ status: "stored" });
});
```

- [ ] **Step 3: Vérifier**

Run: `pnpm -C api test test/confirm.test.ts`
Expected: PASS (5 tests).

- [ ] **Step 4: Commit**

```bash
git add api/src/routes/storage.ts api/test/confirm.test.ts
git commit -m "feat(api): blob confirm with HEAD size reconciliation"
```

---

### Task 8: `POST /v1/storage/blobs/sign-download`

**Files:**
- Modify: `api/src/routes/storage.ts`
- Test: `api/test/sign-download.test.ts`

**Interfaces:**
- Consumes: `presignGet`/`blobKey` (Task 3), `getBucket`/`wasabiCreds`/`SHA256_RE` (Task 6).
- Produces: Route `POST /v1/storage/blobs/sign-download` — body `{sha256}` → 200 `{download_url, expires_at}` ; 404 si le blob n'appartient pas au site ou n'est pas `stored`.

- [ ] **Step 1: Écrire les tests (échec attendu)**

```ts
// api/test/sign-download.test.ts
import { env } from "cloudflare:test";
import { beforeEach, describe, expect, it } from "vitest";
import app from "../src/app";
import { activateTestSite, authHeaders, seedLicenseAndBucket } from "./helpers";

const SHA = "d".repeat(64);

function signDownload(token: string, sha256: string) {
  return app.request(
    "/v1/storage/blobs/sign-download",
    { method: "POST", headers: authHeaders(token), body: JSON.stringify({ sha256 }) },
    env,
  );
}

describe("POST /v1/storage/blobs/sign-download", () => {
  beforeEach(seedLicenseAndBucket);

  it("signs a GET URL for a stored blob", async () => {
    const { site_id, site_token } = await activateTestSite();
    await env.DB.prepare(
      "INSERT INTO blobs (site_id, sha256, size_bytes, status) VALUES (?, ?, 10, 'stored')",
    )
      .bind(site_id, SHA)
      .run();
    const res = await signDownload(site_token, SHA);
    expect(res.status).toBe(200);
    const body = (await res.json()) as { download_url: string; expires_at: string };
    const u = new URL(body.download_url);
    expect(u.pathname).toBe(`/wpback-test-001/s/${site_id}/blobs/${SHA}`);
    expect(u.searchParams.get("X-Amz-Signature")).toMatch(/^[a-f0-9]{64}$/);
  });

  it("returns 404 for pending blobs and for another site's blob", async () => {
    const a = await activateTestSite("https://a.tld");
    const b = await activateTestSite("https://b.tld");
    await env.DB.prepare(
      "INSERT INTO blobs (site_id, sha256, size_bytes, status) VALUES (?, ?, 10, 'pending')",
    )
      .bind(a.site_id, SHA)
      .run();
    expect((await signDownload(a.site_token, SHA)).status).toBe(404);
    expect((await signDownload(b.site_token, SHA)).status).toBe(404);
  });
});
```

Run: `pnpm -C api test test/sign-download.test.ts`
Expected: FAIL — 404 mais avec `error.code = "not_found"` de route absente ; l'assertion du premier test (status 200) échoue.

- [ ] **Step 2: Implémenter (ajout dans `storage.ts`)**

Ajouter `presignGet` à l'import de `../lib/wasabi`, puis :

```ts
storage.post("/blobs/sign-download", async (c) => {
  const site = c.get("site");
  const body = (await c.req.json().catch(() => null)) as { sha256?: unknown } | null;
  if (!body || typeof body.sha256 !== "string" || !SHA256_RE.test(body.sha256)) {
    return apiError(c, 400, "invalid_request", "A valid sha256 is required");
  }

  const blob = await c.env.DB.prepare(
    "SELECT status FROM blobs WHERE site_id = ? AND sha256 = ? AND status = 'stored'",
  )
    .bind(site.id, body.sha256)
    .first<{ status: string }>();
  if (!blob) return apiError(c, 404, "not_found", "Blob not available for this site");

  const bucket = await getBucket(c);
  const presigned = await presignGet(
    wasabiCreds(c.env),
    bucket,
    blobKey(site.id, body.sha256),
  );
  await logUsage(c.env.DB, site.id, "sign_download", body.sha256, null);
  return c.json({ download_url: presigned.url, expires_at: presigned.expiresAt });
});
```

- [ ] **Step 3: Vérifier**

Run: `pnpm -C api test test/sign-download.test.ts`
Expected: PASS (2 tests).

- [ ] **Step 4: Commit**

```bash
git add api/src/routes/storage.ts api/test/sign-download.test.ts
git commit -m "feat(api): blob sign-download scoped to owned stored blobs"
```

---

### Task 9: `POST /v1/snapshots/commit` + `GET /v1/snapshots`

**Files:**
- Create: `api/src/routes/snapshots.ts`
- Modify: `api/src/app.ts`
- Test: `api/test/snapshots.test.ts`

**Interfaces:**
- Consumes: `siteAuth` (Task 5), table `snapshots` (Task 2), blobs `stored` (Tasks 6–7).
- Produces:
  - `POST /v1/snapshots/commit` — body `{ts, manifest_sha256, stats: {files_total, bytes_total, files_changed, bytes_uploaded, db_tables}}` → 201 `{snapshot_id: number}` ; 400 `invalid_request` (ts non ISO UTC ou stats invalides) ; 400 `manifest_missing` si `manifest_sha256` n'est pas un blob `stored` du site.
  - `GET /v1/snapshots` → 200 `{snapshots: [{snapshot_id, ts, manifest_sha256, stats}]}` triés `ts` décroissant, snapshots `complete` uniquement.
  - `interface SnapshotStats { files_total: number; bytes_total: number; files_changed: number; bytes_uploaded: number; db_tables: number }` (export de `snapshots.ts`).

- [ ] **Step 1: Écrire les tests (échec attendu)**

```ts
// api/test/snapshots.test.ts
import { env } from "cloudflare:test";
import { beforeEach, describe, expect, it } from "vitest";
import app from "../src/app";
import { activateTestSite, authHeaders, seedLicenseAndBucket } from "./helpers";

const MANIFEST_SHA = "e".repeat(64);
const STATS = {
  files_total: 100,
  bytes_total: 5000,
  files_changed: 3,
  bytes_uploaded: 300,
  db_tables: 12,
};

function commit(token: string, body: unknown) {
  return app.request(
    "/v1/snapshots/commit",
    { method: "POST", headers: authHeaders(token), body: JSON.stringify(body) },
    env,
  );
}

async function storeManifestBlob(siteId: string) {
  await env.DB.prepare(
    "INSERT INTO blobs (site_id, sha256, size_bytes, status) VALUES (?, ?, 50, 'stored')",
  )
    .bind(siteId, MANIFEST_SHA)
    .run();
}

describe("snapshots", () => {
  beforeEach(seedLicenseAndBucket);

  it("commits a snapshot whose manifest blob is stored", async () => {
    const { site_id, site_token } = await activateTestSite();
    await storeManifestBlob(site_id);
    const res = await commit(site_token, {
      ts: "2026-07-23T04:00:00Z",
      manifest_sha256: MANIFEST_SHA,
      stats: STATS,
    });
    expect(res.status).toBe(201);
    const body = (await res.json()) as { snapshot_id: number };
    expect(body.snapshot_id).toBeGreaterThan(0);
  });

  it("rejects a commit whose manifest is not a stored blob", async () => {
    const { site_token } = await activateTestSite();
    const res = await commit(site_token, {
      ts: "2026-07-23T04:00:00Z",
      manifest_sha256: MANIFEST_SHA,
      stats: STATS,
    });
    expect(res.status).toBe(400);
    const body = (await res.json()) as { error: { code: string } };
    expect(body.error.code).toBe("manifest_missing");
  });

  it("rejects non-UTC-ISO ts and malformed stats", async () => {
    const { site_id, site_token } = await activateTestSite();
    await storeManifestBlob(site_id);
    expect(
      (
        await commit(site_token, {
          ts: "23/07/2026",
          manifest_sha256: MANIFEST_SHA,
          stats: STATS,
        })
      ).status,
    ).toBe(400);
    expect(
      (
        await commit(site_token, {
          ts: "2026-07-23T04:00:00Z",
          manifest_sha256: MANIFEST_SHA,
          stats: { files_total: -1 },
        })
      ).status,
    ).toBe(400);
  });

  it("lists snapshots newest first", async () => {
    const { site_id, site_token } = await activateTestSite();
    await storeManifestBlob(site_id);
    await commit(site_token, {
      ts: "2026-07-22T04:00:00Z",
      manifest_sha256: MANIFEST_SHA,
      stats: STATS,
    });
    await commit(site_token, {
      ts: "2026-07-23T04:00:00Z",
      manifest_sha256: MANIFEST_SHA,
      stats: STATS,
    });
    const res = await app.request(
      "/v1/snapshots",
      { headers: authHeaders(site_token) },
      env,
    );
    expect(res.status).toBe(200);
    const body = (await res.json()) as {
      snapshots: { ts: string; stats: typeof STATS }[];
    };
    expect(body.snapshots.map((s) => s.ts)).toEqual([
      "2026-07-23T04:00:00Z",
      "2026-07-22T04:00:00Z",
    ]);
    expect(body.snapshots[0]?.stats).toEqual(STATS);
  });
});
```

Run: `pnpm -C api test test/snapshots.test.ts`
Expected: FAIL — 404 (routes absentes).

- [ ] **Step 2: Implémenter les routes**

```ts
// api/src/routes/snapshots.ts
import { Hono } from "hono";
import { apiError } from "../lib/errors";
import type { AppEnv } from "../types";

const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
const SHA256_RE = /^[a-f0-9]{64}$/;

export interface SnapshotStats {
  files_total: number;
  bytes_total: number;
  files_changed: number;
  bytes_uploaded: number;
  db_tables: number;
}

const STAT_KEYS: (keyof SnapshotStats)[] = [
  "files_total",
  "bytes_total",
  "files_changed",
  "bytes_uploaded",
  "db_tables",
];

function parseStats(value: unknown): SnapshotStats | null {
  if (typeof value !== "object" || value === null) return null;
  const obj = value as Record<string, unknown>;
  const out = {} as SnapshotStats;
  for (const k of STAT_KEYS) {
    const v = obj[k];
    if (typeof v !== "number" || !Number.isInteger(v) || v < 0) return null;
    out[k] = v;
  }
  return out;
}

export const snapshots = new Hono<AppEnv>();

snapshots.post("/commit", async (c) => {
  const site = c.get("site");
  const body = (await c.req.json().catch(() => null)) as
    | { ts?: unknown; manifest_sha256?: unknown; stats?: unknown }
    | null;
  if (
    !body ||
    typeof body.ts !== "string" ||
    !ISO_UTC_RE.test(body.ts) ||
    typeof body.manifest_sha256 !== "string" ||
    !SHA256_RE.test(body.manifest_sha256)
  ) {
    return apiError(c, 400, "invalid_request", "ts (ISO 8601 UTC) and manifest_sha256 required");
  }
  const stats = parseStats(body.stats);
  if (!stats) return apiError(c, 400, "invalid_request", "stats must contain the 5 integer counters");

  const manifest = await c.env.DB.prepare(
    "SELECT status FROM blobs WHERE site_id = ? AND sha256 = ? AND status = 'stored'",
  )
    .bind(site.id, body.manifest_sha256)
    .first();
  if (!manifest) {
    return apiError(c, 400, "manifest_missing", "Manifest blob is not stored");
  }

  const result = await c.env.DB.prepare(
    "INSERT INTO snapshots (site_id, ts, manifest_sha256, stats_json, status) VALUES (?, ?, ?, ?, 'complete')",
  )
    .bind(site.id, body.ts, body.manifest_sha256, JSON.stringify(stats))
    .run();
  return c.json({ snapshot_id: result.meta.last_row_id }, 201);
});

snapshots.get("/", async (c) => {
  const site = c.get("site");
  const rows = await c.env.DB.prepare(
    "SELECT id, ts, manifest_sha256, stats_json FROM snapshots WHERE site_id = ? AND status = 'complete' ORDER BY ts DESC",
  )
    .bind(site.id)
    .all<{ id: number; ts: string; manifest_sha256: string; stats_json: string }>();
  return c.json({
    snapshots: rows.results.map((r) => ({
      snapshot_id: r.id,
      ts: r.ts,
      manifest_sha256: r.manifest_sha256,
      stats: JSON.parse(r.stats_json) as SnapshotStats,
    })),
  });
});
```

Dans `api/src/app.ts`, ajouter après le montage de `storage` :

```ts
import { snapshots } from "./routes/snapshots";

app.use("/v1/snapshots/*", siteAuth);
app.use("/v1/snapshots", siteAuth);
app.route("/v1/snapshots", snapshots);
```

- [ ] **Step 3: Vérifier**

Run: `pnpm -C api test test/snapshots.test.ts`
Expected: PASS (4 tests).

- [ ] **Step 4: Commit**

```bash
git add api/src/routes/snapshots.ts api/src/app.ts api/test/snapshots.test.ts
git commit -m "feat(api): snapshot commit and list endpoints"
```

---

### Task 10: `GET /v1/usage`

**Files:**
- Create: `api/src/routes/usage.ts`
- Modify: `api/src/app.ts`
- Test: `api/test/usage.test.ts`

**Interfaces:**
- Consumes: `siteAuth` (Task 5), tables `licenses`/`snapshots` (Task 2).
- Produces: `GET /v1/usage` → 200 `{storage_bytes_used, storage_bytes_limit, snapshot_count, retention_days}`.

- [ ] **Step 1: Écrire le test (échec attendu)**

```ts
// api/test/usage.test.ts
import { env } from "cloudflare:test";
import { beforeEach, describe, expect, it } from "vitest";
import app from "../src/app";
import { activateTestSite, authHeaders, seedLicenseAndBucket } from "./helpers";

describe("GET /v1/usage", () => {
  beforeEach(seedLicenseAndBucket);

  it("returns usage, limits and snapshot count", async () => {
    const { site_id, site_token } = await activateTestSite();
    await env.DB.prepare("UPDATE sites SET storage_bytes_used = 4321 WHERE id = ?")
      .bind(site_id)
      .run();
    await env.DB.prepare(
      "INSERT INTO snapshots (site_id, ts, manifest_sha256, stats_json, status) VALUES (?, '2026-07-23T04:00:00Z', ?, '{}', 'complete')",
    )
      .bind(site_id, "f".repeat(64))
      .run();

    const res = await app.request("/v1/usage", { headers: authHeaders(site_token) }, env);
    expect(res.status).toBe(200);
    expect(await res.json()).toEqual({
      storage_bytes_used: 4321,
      storage_bytes_limit: 1_000_000,
      snapshot_count: 1,
      retention_days: 30,
    });
  });
});
```

Run: `pnpm -C api test test/usage.test.ts`
Expected: FAIL — 404 (route absente).

- [ ] **Step 2: Implémenter**

```ts
// api/src/routes/usage.ts
import { Hono } from "hono";
import { apiError } from "../lib/errors";
import type { AppEnv } from "../types";

export const usage = new Hono<AppEnv>();

usage.get("/", async (c) => {
  const site = c.get("site");
  const license = await c.env.DB.prepare(
    "SELECT storage_bytes_limit, retention_days FROM licenses WHERE license_key = ?",
  )
    .bind(site.license_key)
    .first<{ storage_bytes_limit: number; retention_days: number }>();
  if (!license) return apiError(c, 403, "invalid_license", "License not found");

  const count = await c.env.DB.prepare(
    "SELECT COUNT(*) AS n FROM snapshots WHERE site_id = ? AND status = 'complete'",
  )
    .bind(site.id)
    .first<{ n: number }>();

  return c.json({
    storage_bytes_used: site.storage_bytes_used,
    storage_bytes_limit: license.storage_bytes_limit,
    snapshot_count: count?.n ?? 0,
    retention_days: license.retention_days,
  });
});
```

Dans `api/src/app.ts` :

```ts
import { usage } from "./routes/usage";

app.use("/v1/usage", siteAuth);
app.route("/v1/usage", usage);
```

- [ ] **Step 3: Vérifier**

Run: `pnpm -C api test`
Expected: PASS — toute la suite (health, schema, wasabi, activate, auth, sign-upload, confirm, sign-download, snapshots, usage).

- [ ] **Step 4: Commit**

```bash
git add api/src/routes/usage.ts api/src/app.ts api/test/usage.test.ts
git commit -m "feat(api): usage endpoint with quota and snapshot counters"
```

---

### Task 11: GC de rétention (cron mark-and-sweep)

**Files:**
- Create: `api/src/gc.ts`
- Modify: `api/src/index.ts`
- Test: `api/test/gc.test.ts`

**Interfaces:**
- Consumes: `getObjectJson`/`deleteObject`/`blobKey` (Task 3), schéma D1 (Task 2), shape du manifest (Global Constraints).
- Produces:
  - `runGc(env: Env, now: Date): Promise<{ expiredSnapshots: number; deletedBlobs: number }>` (`src/gc.ts`).
  - `interface Manifest { version: number; files: { p: string; h: string; s: number; m: number }[]; db: { table: string; h: string; s: number; checksum: string }[] }` (export de `gc.ts` — clés courtes du manifest plugin ; le blob manifest est gzippé, `getObjectJson` le dégunzippe).
  - Handler `scheduled` branché dans `src/index.ts` (cron `0 3 * * *` déjà déclaré en Task 1).
  - Règles : snapshot `complete` plus vieux que `retention_days` → `expired` ; blob `stored` non référencé par aucun manifest de snapshot vivante (ni comme `manifest_sha256`) → DELETE Wasabi + DELETE D1 + décrément `storage_bytes_used` ; blob `pending` plus vieux que 24 h → DELETE (sans décrément) ; si la lecture d'un manifest échoue, le site entier est sauté (aucune suppression).

- [ ] **Step 1: Écrire les tests avec fetchMock (échec attendu)**

```ts
// api/test/gc.test.ts
import { env, fetchMock } from "cloudflare:test";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { runGc, type Manifest } from "../src/gc";
import { activateTestSite, seedLicenseAndBucket } from "./helpers";

const S3_ORIGIN = "https://s3.eu-central-1.wasabisys.com";
const NOW = new Date("2026-07-23T03:00:00Z");
const LIVE_SHA = "1".repeat(64);
const ORPHAN_SHA = "2".repeat(64);
const MANIFEST_SHA = "3".repeat(64);
const STALE_PENDING_SHA = "4".repeat(64);

beforeAll(() => {
  fetchMock.activate();
  fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());

async function insertBlob(siteId: string, sha: string, status: string, createdAt: string) {
  await env.DB.prepare(
    "INSERT INTO blobs (site_id, sha256, size_bytes, status, created_at) VALUES (?, ?, 100, ?, ?)",
  )
    .bind(siteId, sha, status, createdAt)
    .run();
}

async function gzipBytes(value: unknown): Promise<Uint8Array> {
  const stream = new Response(JSON.stringify(value)).body!.pipeThrough(
    new CompressionStream("gzip"),
  );
  return new Uint8Array(await new Response(stream).arrayBuffer());
}

describe("runGc", () => {
  beforeEach(seedLicenseAndBucket); // retention_days = 30

  it("expires old snapshots, deletes orphan and stale pending blobs, keeps referenced ones", async () => {
    const { site_id } = await activateTestSite();
    // Snapshot vivante (hier) + snapshot expirée (il y a 60 jours)
    await insertBlob(site_id, MANIFEST_SHA, "stored", "2026-07-22T00:00:00Z");
    await insertBlob(site_id, LIVE_SHA, "stored", "2026-07-22T00:00:00Z");
    await insertBlob(site_id, ORPHAN_SHA, "stored", "2026-05-01T00:00:00Z");
    await insertBlob(site_id, STALE_PENDING_SHA, "pending", "2026-07-20T00:00:00Z");
    await env.DB.prepare("UPDATE sites SET storage_bytes_used = 300 WHERE id = ?")
      .bind(site_id)
      .run();
    await env.DB.prepare(
      "INSERT INTO snapshots (site_id, ts, manifest_sha256, stats_json, status) VALUES (?, '2026-07-22T04:00:00Z', ?, '{}', 'complete'), (?, '2026-05-24T04:00:00Z', ?, '{}', 'complete')",
    )
      .bind(site_id, MANIFEST_SHA, site_id, ORPHAN_SHA)
      .run();

    const manifest: Manifest = {
      version: 1,
      files: [{ p: "wp-content/x.php", h: LIVE_SHA, s: 100, m: 1721730000 }],
      db: [],
    };
    fetchMock
      .get(S3_ORIGIN)
      .intercept({
        path: `/wpback-test-001/s/${site_id}/blobs/${MANIFEST_SHA}`,
        method: "GET",
      })
      .reply(200, await gzipBytes(manifest)); // le plugin uploade le manifest gzippé
    fetchMock
      .get(S3_ORIGIN)
      .intercept({
        path: `/wpback-test-001/s/${site_id}/blobs/${ORPHAN_SHA}`,
        method: "DELETE",
      })
      .reply(204, "");
    fetchMock
      .get(S3_ORIGIN)
      .intercept({
        path: `/wpback-test-001/s/${site_id}/blobs/${STALE_PENDING_SHA}`,
        method: "DELETE",
      })
      .reply(204, "");

    const result = await runGc(env, NOW);
    expect(result.expiredSnapshots).toBe(1);
    expect(result.deletedBlobs).toBe(2);

    const remaining = await env.DB.prepare(
      "SELECT sha256 FROM blobs WHERE site_id = ? ORDER BY sha256",
    )
      .bind(site_id)
      .all<{ sha256: string }>();
    expect(remaining.results.map((r) => r.sha256)).toEqual([LIVE_SHA, MANIFEST_SHA]);

    const site = await env.DB.prepare("SELECT storage_bytes_used FROM sites WHERE id = ?")
      .bind(site_id)
      .first<{ storage_bytes_used: number }>();
    expect(site?.storage_bytes_used).toBe(200); // -100 (orphan stored), pending sans décrément
  });

  it("skips the whole site when a live manifest cannot be read", async () => {
    const { site_id } = await activateTestSite();
    await insertBlob(site_id, MANIFEST_SHA, "stored", "2026-07-22T00:00:00Z");
    await insertBlob(site_id, ORPHAN_SHA, "stored", "2026-05-01T00:00:00Z");
    await env.DB.prepare(
      "INSERT INTO snapshots (site_id, ts, manifest_sha256, stats_json, status) VALUES (?, '2026-07-22T04:00:00Z', ?, '{}', 'complete')",
    )
      .bind(site_id, MANIFEST_SHA)
      .run();
    fetchMock
      .get(S3_ORIGIN)
      .intercept({
        path: `/wpback-test-001/s/${site_id}/blobs/${MANIFEST_SHA}`,
        method: "GET",
      })
      .reply(500, "");

    const result = await runGc(env, NOW);
    expect(result.deletedBlobs).toBe(0);
    const count = await env.DB.prepare("SELECT COUNT(*) AS n FROM blobs WHERE site_id = ?")
      .bind(site_id)
      .first<{ n: number }>();
    expect(count?.n).toBe(2);
  });
});
```

Run: `pnpm -C api test test/gc.test.ts`
Expected: FAIL — `Cannot find module '../src/gc'`.

- [ ] **Step 2: Implémenter le GC**

```ts
// api/src/gc.ts
import { blobKey, deleteObject, getObjectJson } from "./lib/wasabi";
import { wasabiCreds } from "./routes/storage";
import type { Env } from "./types";

// Clés courtes du manifest plugin : p = path, h = sha256, s = size, m = mtime.
// Le blob manifest est gzippé par le plugin ; getObjectJson le dégunzippe.
export interface Manifest {
  version: number;
  files: { p: string; h: string; s: number; m: number }[];
  db: { table: string; h: string; s: number; checksum: string }[];
}

const PENDING_MAX_AGE_MS = 24 * 3600 * 1000;

interface GcSiteRow {
  id: string;
  bucket_name: string;
  bucket_region: string;
}

export async function runGc(
  env: Env,
  now: Date,
): Promise<{ expiredSnapshots: number; deletedBlobs: number }> {
  const creds = wasabiCreds(env);
  const nowIso = now.toISOString();

  const expired = await env.DB.prepare(
    `UPDATE snapshots SET status = 'expired'
     WHERE status = 'complete'
       AND ts < (
         SELECT strftime('%Y-%m-%dT%H:%M:%SZ', datetime(?1, '-' || l.retention_days || ' days'))
         FROM sites s JOIN licenses l ON l.license_key = s.license_key
         WHERE s.id = snapshots.site_id
       )`,
  )
    .bind(nowIso)
    .run();

  const sites = await env.DB.prepare(
    `SELECT s.id, b.name AS bucket_name, b.region AS bucket_region
     FROM sites s JOIN buckets b ON b.id = s.bucket_id`,
  ).all<GcSiteRow>();

  let deletedBlobs = 0;

  for (const site of sites.results) {
    const bucket = { name: site.bucket_name, region: site.bucket_region };
    const referenced = new Set<string>();

    try {
      const live = await env.DB.prepare(
        "SELECT manifest_sha256 FROM snapshots WHERE site_id = ? AND status = 'complete'",
      )
        .bind(site.id)
        .all<{ manifest_sha256: string }>();

      for (const row of live.results) {
        referenced.add(row.manifest_sha256);
        const manifest = await getObjectJson<Manifest>(
          creds,
          bucket,
          blobKey(site.id, row.manifest_sha256),
        );
        for (const f of manifest.files) referenced.add(f.h);
        for (const d of manifest.db) referenced.add(d.h);
      }
    } catch (err) {
      console.error(`gc: skipping site ${site.id}, manifest read failed`, err);
      continue;
    }

    const blobs = await env.DB.prepare(
      "SELECT sha256, size_bytes, status, created_at FROM blobs WHERE site_id = ?",
    )
      .bind(site.id)
      .all<{ sha256: string; size_bytes: number; status: string; created_at: string }>();

    let freed = 0;
    for (const b of blobs.results) {
      const orphanStored = b.status === "stored" && !referenced.has(b.sha256);
      const stalePending =
        b.status === "pending" &&
        now.getTime() - Date.parse(b.created_at) > PENDING_MAX_AGE_MS;
      if (!orphanStored && !stalePending) continue;

      await deleteObject(creds, bucket, blobKey(site.id, b.sha256));
      await env.DB.prepare("DELETE FROM blobs WHERE site_id = ? AND sha256 = ?")
        .bind(site.id, b.sha256)
        .run();
      deletedBlobs += 1;
      if (b.status === "stored") freed += b.size_bytes;
    }

    if (freed > 0) {
      await env.DB.prepare(
        "UPDATE sites SET storage_bytes_used = MAX(0, storage_bytes_used - ?) WHERE id = ?",
      )
        .bind(freed, site.id)
        .run();
    }
  }

  return { expiredSnapshots: expired.meta.changes ?? 0, deletedBlobs };
}
```

Remplacer `api/src/index.ts` par :

```ts
// api/src/index.ts
import app from "./app";
import { runGc } from "./gc";
import type { Env } from "./types";

export default {
  fetch: app.fetch,
  async scheduled(_controller, env, ctx) {
    ctx.waitUntil(
      runGc(env, new Date()).then((r) =>
        console.log(`gc: ${r.expiredSnapshots} snapshots expired, ${r.deletedBlobs} blobs deleted`),
      ),
    );
  },
} satisfies ExportedHandler<Env>;
```

- [ ] **Step 3: Vérifier**

Run: `pnpm -C api test test/gc.test.ts`
Expected: PASS (2 tests).

- [ ] **Step 4: Commit**

```bash
git add api/src/gc.ts api/src/index.ts api/test/gc.test.ts
git commit -m "feat(api): retention GC cron with mark-and-sweep over live manifests"
```

---

### Task 12: Rate limiting par site

**Files:**
- Create: `api/src/middleware/rate-limit.ts`
- Modify: `api/src/app.ts`
- Test: `api/test/rate-limit.test.ts`

**Interfaces:**
- Consumes: `siteAuth` (Task 5 — le rate limit lit `c.get("site")`, il se monte donc APRÈS `siteAuth`).
- Produces:
  - `checkRateLimit(siteId: string, now: number): boolean` et `resetRateLimits(): void` (fenêtre fixe 60 s, 120 requêtes max, compteur en mémoire d'isolat).
  - `rateLimit: MiddlewareHandler<AppEnv>` → 429 `{error: {code: "rate_limited", …}}` au-delà de la limite.

Limite assumée : le compteur est par isolat Workers (pas global). C'est un garde-fou anti-boucle folle du plugin, pas une protection DDoS — suffisant pour la V1 et documenté ici.

- [ ] **Step 1: Écrire les tests (échec attendu)**

```ts
// api/test/rate-limit.test.ts
import { env } from "cloudflare:test";
import { beforeEach, describe, expect, it } from "vitest";
import app from "../src/app";
import {
  checkRateLimit,
  resetRateLimits,
} from "../src/middleware/rate-limit";
import { activateTestSite, authHeaders, seedLicenseAndBucket } from "./helpers";

describe("checkRateLimit", () => {
  beforeEach(resetRateLimits);

  it("allows 120 requests per window then blocks", () => {
    const t0 = 1_000_000;
    for (let i = 0; i < 120; i++) {
      expect(checkRateLimit("site-a", t0 + i)).toBe(true);
    }
    expect(checkRateLimit("site-a", t0 + 500)).toBe(false);
  });

  it("resets after the 60s window and isolates sites", () => {
    const t0 = 1_000_000;
    for (let i = 0; i < 121; i++) checkRateLimit("site-a", t0);
    expect(checkRateLimit("site-a", t0)).toBe(false);
    expect(checkRateLimit("site-b", t0)).toBe(true);
    expect(checkRateLimit("site-a", t0 + 60_001)).toBe(true);
  });
});

describe("rate limited routes", () => {
  beforeEach(async () => {
    resetRateLimits();
    await seedLicenseAndBucket();
  });

  it("returns 429 with the uniform error shape once over the limit", async () => {
    const { site_token } = await activateTestSite();
    let last: Response | null = null;
    for (let i = 0; i < 121; i++) {
      last = await app.request("/v1/usage", { headers: authHeaders(site_token) }, env);
    }
    expect(last?.status).toBe(429);
    const body = (await last?.json()) as { error: { code: string } };
    expect(body.error.code).toBe("rate_limited");
  });
});
```

Run: `pnpm -C api test test/rate-limit.test.ts`
Expected: FAIL — `Cannot find module '../src/middleware/rate-limit'`.

- [ ] **Step 2: Implémenter le middleware et le monter**

```ts
// api/src/middleware/rate-limit.ts
import { createMiddleware } from "hono/factory";
import { apiError } from "../lib/errors";
import type { AppEnv } from "../types";

const WINDOW_MS = 60_000;
const MAX_REQUESTS = 120;

const counters = new Map<string, { windowStart: number; count: number }>();

export function checkRateLimit(siteId: string, now: number): boolean {
  const entry = counters.get(siteId);
  if (!entry || now - entry.windowStart >= WINDOW_MS) {
    counters.set(siteId, { windowStart: now, count: 1 });
    return true;
  }
  entry.count += 1;
  return entry.count <= MAX_REQUESTS;
}

export function resetRateLimits(): void {
  counters.clear();
}

export const rateLimit = createMiddleware<AppEnv>(async (c, next) => {
  const site = c.get("site");
  if (!checkRateLimit(site.id, Date.now())) {
    return apiError(c, 429, "rate_limited", "Too many requests for this site");
  }
  await next();
});
```

Dans `api/src/app.ts`, remplacer les trois montages d'auth par auth + rate limit :

```ts
import { rateLimit } from "./middleware/rate-limit";

app.use("/v1/storage/*", siteAuth, rateLimit);
app.use("/v1/snapshots/*", siteAuth, rateLimit);
app.use("/v1/snapshots", siteAuth, rateLimit);
app.use("/v1/usage", siteAuth, rateLimit);
```

- [ ] **Step 3: Vérifier la suite complète + lint + typecheck**

Run: `pnpm -C api test && pnpm -C api lint && pnpm -C api typecheck`
Expected: PASS — suite complète verte, oxlint 0 erreur, tsc silencieux.

- [ ] **Step 4: Commit**

```bash
git add api/src/middleware/rate-limit.ts api/src/app.ts api/test/rate-limit.test.ts
git commit -m "feat(api): per-site fixed-window rate limiting"
```
