# Capability Spec: api-plugin-file-upload **Package**: `@suaveplan/api-plugin-file-upload` **Status**: SHIPPED ## Requirements ### Requirement: createFileUploadPlugin returns a Zod-validated PluginDescriptor The package SHALL export `createFileUploadPlugin(options): PluginDescriptor` validating `options` against `FileUploadPluginOptionsSchema` at the factory boundary. Invalid input SHALL raise `FileUploadError(UPLOAD.INVALID_INPUT)` carrying the `ZodError` as `cause`. The returned descriptor SHALL declare `name: "file-upload"`, the resolved `defaults`, and `onInit`/`onClose` lifecycle hooks. #### Scenario: factory produces a valid descriptor - **WHEN** `createFileUploadPlugin({})` is called - **THEN** the returned descriptor satisfies `PluginDescriptor` with `name === "file-upload"` #### Scenario: invalid options raise UPLOAD.INVALID_INPUT - **WHEN** `createFileUploadPlugin({ basePath: 123 } as never)` is called - **THEN** `FileUploadError(UPLOAD.INVALID_INPUT)` is thrown with `cause instanceof ZodError` **Verified by:** `src/plugin/plugin.test.ts::"createFileUploadPlugin returns valid PluginDescriptor"` AND `src/plugin/plugin.test.ts::"invalid options raise UPLOAD_INVALID_INPUT"` --- ### Requirement: onInit registers 6 OpenAPI routes mounted under basePath The descriptor's `onInit(ctx)` hook SHALL resolve `app` and `openAPIRegistry` from the `ServiceRegistry`, build 6 typed routes via `buildUploadRoutes`, and register them through `definePluginRoutes` under `config.basePath` (default `/uploads`). When `openAPIRegistry` is absent, `onInit` SHALL fall back to mounting Hono routes directly and log a warning. Missing `app` SHALL throw `FileUploadError(UPLOAD.SERVICE_NOT_AVAILABLE)`. #### Scenario: routes registered through openAPIRegistry - **WHEN** `ctx` provides both `app` and `openAPIRegistry` and the plugin initializes - **THEN** `registry.getRoutesByPlugin("file-upload")` returns 6 entries #### Scenario: missing app raises UPLOAD.SERVICE_NOT_AVAILABLE - **WHEN** `onInit` runs with no `app` in `ServiceRegistry` - **THEN** `FileUploadError(UPLOAD.SERVICE_NOT_AVAILABLE)` is thrown listing `app` as missing **Verified by:** `src/plugin/plugin.test.ts::"onInit registers 6 routes via definePluginRoutes"` AND `src/plugin/plugin.test.ts::"missing app raises UPLOAD_SERVICE_NOT_AVAILABLE"` --- ### Requirement: All 6 routes are requireAuth-gated and scoped to the request principal Every route mounted by the plugin SHALL be wrapped by `requireAuth()`. Each route handler SHALL read `c.get("user")!.id` as the owning principal and `c.get("tenant")?.id` (optional) as tenant scope. Foreign `uploadId`s — uploads whose recorded principal does not match the current request's principal — SHALL respond `404 Not Found` (anti-enumeration). The 404 SHALL be `FileUploadError(UPLOAD.NOT_FOUND)`, never `UPLOAD.FORBIDDEN`. #### Scenario: anonymous request rejected with 401 - **WHEN** any of the 6 routes is hit without a valid auth principal - **THEN** the global `requireAuth()` middleware throws `AuthError(AUTH.UNAUTHENTICATED)` and the response is HTTP 401 #### Scenario: foreign uploadId returns 404 (anti-enumeration) - **WHEN** `GET /uploads/{uploadId}` targets an upload created by another principal - **THEN** the handler raises `FileUploadError(UPLOAD.NOT_FOUND)` and the response is HTTP 404 **Verified by:** `tests/integration/full-flow.test.ts::"anonymous request is rejected with 401"` AND `tests/integration/full-flow.test.ts::"foreign uploadId returns 404 (anti-enumeration)"` --- ### Requirement: Single-shot POST /uploads validates size, MIME, and stores the file `POST /uploads` SHALL accept `multipart/form-data` with a single `file` part. It SHALL reject the upload when: - the declared part size exceeds `config.singleShotLimitBytes` (default 10 MiB) → `FileUploadError(UPLOAD.TOO_LARGE)` → HTTP 413, - the declared MIME type is not in `config.allowedMimeTypes` (when set) or is in `config.deniedMimeTypes` (defaults include common executable types) → `FileUploadError(UPLOAD.MIME_REJECTED)` → HTTP 415, - the sniffed magic-bytes content type via `@suaveplan/mime` disagrees with the declared `Content-Type` → `FileUploadError(UPLOAD.MIME_REJECTED)` with `context.reason === "sniff-mismatch"`. On success the route SHALL persist the bytes via `UploadStorage.put`, compute SHA-256, record an `UploadRecord`, and respond `200` with `{ uploadId, size, sha256, mimeType }`. #### Scenario: single-shot upload succeeds - **WHEN** an authenticated request POSTs a 1 KiB `text/plain` file - **THEN** the response is HTTP 200 with a typed body matching `SingleShotResponseSchema` #### Scenario: oversize single-shot upload rejected with 413 - **WHEN** the part size exceeds `singleShotLimitBytes` - **THEN** the handler raises `FileUploadError(UPLOAD.TOO_LARGE)` → HTTP 413 #### Scenario: spoofed MIME rejected - **WHEN** a PNG byte-stream is uploaded with declared `Content-Type: text/plain` - **THEN** the handler raises `FileUploadError(UPLOAD.MIME_REJECTED)` with `context.reason === "sniff-mismatch"` → HTTP 415 **Verified by:** `tests/integration/full-flow.test.ts::"single-shot upload round-trip"` AND `tests/integration/full-flow.test.ts::"oversize single-shot upload rejected with 413"` AND `tests/integration/full-flow.test.ts::"MIME spoof rejected"` --- ### Requirement: Chunked upload — initiate, append, complete, abort, status The chunked protocol SHALL expose: - `POST /uploads/initiate` → creates an `UploadSession` and returns `{ uploadId, chunkSize, totalChunks }`. Body validated by `InitiateUploadBodySchema` carrying `fileName`, `totalSize`, `totalSha256`, and optional `mimeType` + `chunkSize`. Reject `totalSize > config.maxFileSizeBytes` with `UPLOAD.TOO_LARGE`. - `PUT /uploads/{uploadId}/chunks/{chunkIndex}` → accepts raw bytes for the indexed chunk. SHALL raise `UPLOAD.CHUNK_OUT_OF_ORDER` (HTTP 409) when `chunkIndex >= totalChunks` or `chunkIndex < 0`. Re-uploading an already-received chunk SHALL be idempotent (200, no-op). - `POST /uploads/{uploadId}/complete` → asserts every chunk index in `[0, totalChunks)` is present, concatenates, computes SHA-256, and verifies against the client-declared `totalSha256`. Mismatch SHALL raise `UPLOAD.HASH_MISMATCH` (HTTP 422). Missing chunks raise `UPLOAD.INCOMPLETE` (HTTP 409). On success: deletes the session, writes the assembled bytes via `UploadStorage.put`, returns the same `SingleShotResponseSchema` shape. - `DELETE /uploads/{uploadId}` → idempotent abort: removes the session and any partial bytes; responds 204 whether or not the session existed (foreign principal → 404 first). - `GET /uploads/{uploadId}` → returns `{ uploadId, totalChunks, receivedChunks: number[], state: "in-progress" | "complete" }` so clients can resume. Sessions whose `createdAt + config.sessionTtlMs` is in the past SHALL be eligible for cleanup; `appendChunk`/`complete` against an expired session SHALL raise `UPLOAD.SESSION_EXPIRED` (HTTP 410). #### Scenario: chunked round-trip succeeds - **WHEN** an authenticated client initiates, uploads N chunks, then completes a 80 MiB payload in 5 MiB chunks - **THEN** the assembled bytes match the original and the response is HTTP 200 #### Scenario: resume after crash mid-upload - **WHEN** a client uploads chunks 0..K, restarts, then calls `GET /uploads/{uploadId}` - **THEN** the response includes `receivedChunks` containing exactly `[0..K]` and the client may resume from `K+1` #### Scenario: hash mismatch on complete - **WHEN** the client-declared `totalSha256` does not match the assembled SHA-256 - **THEN** `complete` raises `UPLOAD.HASH_MISMATCH` (HTTP 422) #### Scenario: chunk index out of range - **WHEN** `PUT /uploads/{uploadId}/chunks/999` is hit on a session with `totalChunks=3` - **THEN** `UPLOAD.CHUNK_OUT_OF_ORDER` (HTTP 409) is raised #### Scenario: abort is idempotent - **WHEN** `DELETE /uploads/{uploadId}` is called twice - **THEN** both calls return 204 (the second is a no-op) **Verified by:** `tests/integration/full-flow.test.ts::"chunked upload of 80MB in 5MB chunks"` AND `tests/integration/full-flow.test.ts::"resume after simulated crash mid-upload"` AND `src/assembler/assembler.test.ts::"hash mismatch raises UPLOAD_HASH_MISMATCH"` AND `src/services/file-upload-service.test.ts::"chunk index out of range raises UPLOAD_CHUNK_OUT_OF_ORDER"` AND `src/services/file-upload-service.test.ts::"abort is idempotent"` --- ### Requirement: fileUploadService registered in ServiceRegistry with typed slot `onInit` SHALL call `ctx.registerService("fileUploadService", service)` exposing 6 methods: `initiate`, `appendChunk`, `complete`, `abort`, `get`, `delete`. The package SHALL augment `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` so downstream plugins get `ctx.getService("fileUploadService")` typed as `FileUploadService | undefined`. #### Scenario: downstream plugin reads typed slot - **WHEN** a sibling plugin calls `ctx.getService("fileUploadService")` - **THEN** the return type is `FileUploadService | undefined` with no cast **Verified by:** `src/services/file-upload-service.spec-conformance.test.ts::"ServiceRegistry slot typed as FileUploadService"` AND `src/services/file-upload-service.test.ts::"service exposes 6 methods"` --- ### Requirement: Pluggable UploadStorage adapter The package SHALL export an `UploadStorage` interface with `put(key, bytes)`, `get(key)`, `delete(key)`, and `exists(key)` methods. Two concrete implementations SHALL ship: `MemoryUploadStorage` (test-only — documented as such) and `FileSystemUploadStorage` (production — backed by `node:fs/promises` writing under a `rootDir` with sanitized keys). `FileSystemUploadStorage` SHALL refuse paths that escape `rootDir` (path-traversal) by raising `FileUploadError(UPLOAD.STORAGE_FAILURE)`. #### Scenario: FileSystemUploadStorage rejects path traversal - **WHEN** `storage.put("../etc/passwd", bytes)` is called - **THEN** `FileUploadError(UPLOAD.STORAGE_FAILURE)` is thrown #### Scenario: MemoryUploadStorage round-trip - **WHEN** `storage.put("k", bytes)` then `storage.get("k")` is called - **THEN** the returned bytes match exactly **Verified by:** `src/storage/file-system.test.ts::"rejects path traversal"` AND `src/storage/file-system.test.ts::"put/get/delete round-trip"` AND `src/storage/memory.test.ts::"put/get/delete round-trip"` --- ### Requirement: Pluggable UploadSessionStore The package SHALL export an `UploadSessionStore` interface with `create`, `read`, `update`, `delete`, `appendChunkIndex`, `listExpired`. `MemoryUploadSessionStore` SHALL ship as the default. Stores SHALL be injectable through `FileUploadPluginOptions.sessionStore`. #### Scenario: appendChunkIndex is idempotent - **WHEN** `appendChunkIndex(id, 3)` is called twice - **THEN** the resulting `receivedChunks` set contains exactly one `3` **Verified by:** `src/session-store/memory.test.ts::"appendChunkIndex is idempotent"` AND `src/session-store/memory.test.ts::"create/read/update/delete round-trip"` AND `src/session-store/memory.test.ts::"listExpired returns sessions past TTL"` --- ### Requirement: Every UPLOAD_* code is thrown from a production path `UPLOAD_CODES` SHALL define exactly 11 codes (`INVALID_INPUT`, `NOT_FOUND`, `FORBIDDEN`, `MIME_REJECTED`, `TOO_LARGE`, `CHUNK_OUT_OF_ORDER`, `HASH_MISMATCH`, `SESSION_EXPIRED`, `STORAGE_FAILURE`, `INCOMPLETE`, `SERVICE_NOT_AVAILABLE`). Every code SHALL be raised by at least one production call site exercised by tests. **Verified by:** `src/error-codes/error-codes.spec-conformance.test.ts::"every UPLOAD_* code is thrown from a production path"` --- ### Requirement: OpenTelemetry instruments emit per operation The package SHALL register counters `upload.singleshot_total{outcome}`, `upload.chunks_total{outcome}`, `upload.completes_total{outcome}`, `upload.bytes_total` and histogram `upload.complete_duration_ms`. Counters fire on every code path (success and failure); the histogram fires on every `complete` invocation regardless of outcome. #### Scenario: counter + histogram emit on complete - **WHEN** a chunked upload is completed against a real in-memory `MeterProvider` - **THEN** both `upload.completes_total{outcome="success"}` AND `upload.complete_duration_ms` have observations **Verified by:** `src/otel/instruments.test.ts::"complete emits counter + histogram"` AND `src/otel/instruments.test.ts::"singleshot emits counter and bytes"` --- ### Requirement: Server-tier package shape and 100% coverage The package SHALL run in Bun + Node. `package.json` SHALL declare per-feature subpath exports for `./plugin/plugin`, `./error-codes/error-codes`, `./otel/instruments`, `./storage/memory`, `./storage/file-system`, `./session-store/memory`, `./services/file-upload-service`, `./assembler/assembler`, `./validators/mime`, `./validators/size`, `./routes/routes`, `./schemas/options`, `./schemas/routes`, `./types/types`. Every implementation `.ts` SHALL have a sibling `.md` ≥200 words. Feature barrels SHALL use explicit named re-exports (no `export *`). 100/100/100/100 coverage. #### Scenario: subpath exports resolve - **WHEN** `tests/dep-graph.test.ts` walks `package.json` exports - **THEN** every subpath maps to an existing `src/` file **Verified by:** `tests/dep-graph.test.ts::"every subpath export resolves"` AND `tests/dep-graph.test.ts::"every impl .ts has a sibling .md"` AND `tests/dep-graph.test.ts::"package.json carries required dependencies"`