Initial Commit
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
# Add @suaveplan/api-plugin-file-upload
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-file-upload`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/api-plugin-file-upload/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
`@suaveplan/api` apps repeatedly hand-roll multipart and chunked upload routes — every consumer rebuilds size limits, MIME allow-listing, hash verification, session bookkeeping, and per-user isolation from scratch and gets at least one of them wrong. A single composable plugin that bolts on top of `@suaveplan/api-plugin-auth` removes that surface from every downstream service and gives the same OTel + error-code observability every other plugin already provides.
|
||||
|
||||
The upstream `enterprise/api-plugin-file-upload.md` audit calls out concrete gaps in the legacy implementation: no HTTP routes shipped, `as any` event casts, in-memory state with no persistence, deterministic-ID concerns, no SHA-256 hashing. Genesis ships routes from day one, decouples the storage and session-store contracts, and wires the same auth/principal pipeline every sibling plugin uses.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `createFileUploadPlugin(options): PluginDescriptor<FileUploadPluginConfig>` — Zod-validated factory mounting 6 routes through `definePluginRoutes`.
|
||||
- ADDED: 6 Hono routes — `POST /uploads`, `POST /uploads/initiate`, `PUT /uploads/{uploadId}/chunks/{chunkIndex}`, `POST /uploads/{uploadId}/complete`, `DELETE /uploads/{uploadId}`, `GET /uploads/{uploadId}` — every route `requireAuth()`-gated.
|
||||
- ADDED: `UploadStorage` interface + `MemoryUploadStorage` (test-only) + `FileSystemUploadStorage` (production) implementations.
|
||||
- ADDED: `UploadSessionStore` interface + `MemoryUploadSessionStore` implementation.
|
||||
- ADDED: `fileUploadService` typed slot in `ServiceRegistry` exposing `initiate`/`appendChunk`/`complete`/`abort`/`get`/`delete`.
|
||||
- ADDED: 11 `UPLOAD_*` error codes (`INVALID_INPUT`, `NOT_FOUND`, `FORBIDDEN`, `MIME_REJECTED`, `TOO_LARGE`, `CHUNK_OUT_OF_ORDER`, `HASH_MISMATCH`, `SESSION_EXPIRED`, `STORAGE_FAILURE`, `INCOMPLETE`, `SERVICE_NOT_AVAILABLE`).
|
||||
- ADDED: 4 OTel counters (`upload.singleshot_total{outcome}`, `upload.chunks_total{outcome}`, `upload.completes_total{outcome}`, `upload.bytes_total`) + 1 histogram (`upload.complete_duration_ms`).
|
||||
- ADDED: MIME sniff cross-check via `@suaveplan/mime`; optional image dimension cap stub via `@suaveplan/exif`.
|
||||
- ADDED: Cross-user isolation — foreign `uploadId` returns 404 (anti-enumeration) at every route.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Persistent (SurrealDB) session/upload storage — `MemoryUploadSessionStore` + `FileSystemUploadStorage` ship; a Surreal-backed `UploadSessionStore` is a follow-up because `api-plugin-database` integration is deferred to its own change.
|
||||
- Virus-scan hooks (ClamAV / VirusTotal) — `ScanHook` shape is defined but no concrete adapters; the hook slot is extension-ready.
|
||||
- TUS 1.0 wire-protocol compatibility — the chunked protocol is genesis-native; a TUS adapter is a follow-up change.
|
||||
- Image transform pipeline (`compress-image`, PDF) — deferred until those packages ship downstream.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from `../suaveplan/enterprise/api-plugin-file-upload.md`.
|
||||
|
||||
| Requirement | Source lines | Notes |
|
||||
|---|---|---|
|
||||
| Plugin factory exports `createFileUploadPlugin` + service slot | L17-L19, L42-L43 | enhanced — routes shipped from day one |
|
||||
| 6 HTTP routes mounted under `config.basePath` | L42-L43, L94 | verbatim — adds `GET /uploads/{id}` for resume status |
|
||||
| `UploadStorage` adapter interface + MemoryUploadStorage + FileSystemUploadStorage | L25, L52, L97 | verbatim — drops S3 (deferred until `storage-s3` lands fully) |
|
||||
| Pluggable `UploadSessionStore` (in-memory default) | L24, L36, L46 | verbatim — replaces in-process Map with injectable contract |
|
||||
| SHA-256 hash of assembled content on complete | L48 | verbatim |
|
||||
| MIME validation + sniff cross-check | L64 | verbatim — uses `@suaveplan/mime` |
|
||||
| Optional `image/*` dimension cap | L64 | enhanced — uses `@suaveplan/exif` |
|
||||
| Stable error-code registry | L93 | enhanced — 11 codes via `defineCodes` |
|
||||
| OTel counters + histogram | L52 | enhanced — names normalized to genesis convention |
|
||||
| AuthN required on every route via `requireAuth()` | L49 | verbatim |
|
||||
| Cross-user isolation (404 on foreign uploadId) | L98 (verified persistence ≈ isolation) | enhanced — anti-enumeration explicit |
|
||||
| Resume capability — `GET /uploads/{id}` returns received chunk indices | L42-L43 (chunked protocol) | enhanced — explicit resume contract |
|
||||
| Session cleanup on TTL | L96 | verbatim |
|
||||
| Zod schemas at boundary | Cross-cutting §3 / §6 | verbatim |
|
||||
| 100% test coverage with `provider: istanbul` | L90 | verbatim |
|
||||
| Sibling `.md` per impl `.ts` | L91-L92 | verbatim |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| `fileUploadService` typed `ServiceRegistry` slot | Genesis-wide pattern: every plugin registers a typed service so siblings get inference (mirrors `authService`). |
|
||||
| `expectTypeOf` assertions on public surface | Genesis monorepo policy — every public type is pinned. |
|
||||
| `bun scripts/openspec-conformance.ts` exit 0 | Genesis-wide gate. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/plugin/plugin.test.ts` — factory validation, descriptor shape, lifecycle hooks.
|
||||
- `src/error-codes/error-codes.test.ts` — registry shape + every code thrown from production path.
|
||||
- `src/error-codes/error-codes.spec-conformance.test.ts` — every `UPLOAD_*` code wired in production.
|
||||
- `src/otel/instruments.test.ts` — counters + histogram emit on every route via real `MeterProvider`.
|
||||
- `src/storage/file-system.test.ts` + `src/storage/memory.test.ts` — adapter contracts.
|
||||
- `src/session-store/memory.test.ts` — session-store contract.
|
||||
- `src/assembler/assembler.test.ts` — chunk assembly + SHA-256 verify + resume.
|
||||
- `src/validators/mime.test.ts` + `src/validators/size.test.ts` — allowlist/denylist + sniff cross-check + size cap.
|
||||
- `src/routes/build-routes.test.ts` — 6 routes built with `RouteBuilder`.
|
||||
- `src/services/file-upload-service.test.ts` + `.spec-conformance.test.ts` — service contract + registry-slot typing.
|
||||
- `src/middleware/context-augmentation.ts` (no test, types-only).
|
||||
- `tests/integration/full-flow.test.ts` — real `PluginRegistry` + real `api` + real `api-plugin-auth` + 6-route round-trip (single-shot, chunked, resume, abort, isolation).
|
||||
- `tests/property.test.ts` — fast-check 200-run property test on `InitiateUploadBodySchema`.
|
||||
- `tests/dep-graph.test.ts` — package layout invariants.
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. No existing genesis package depends on `@suaveplan/api-plugin-file-upload`.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/api-plugin-file-upload` to enumerate. No dependents exist on landing — this is the first change introducing the package.
|
||||
|
||||
No dependents (verified by `bunx turbo ls --filter=...@suaveplan/api-plugin-file-upload`).
|
||||
@@ -0,0 +1,212 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-plugin-file-upload (add-api-plugin-file-upload)
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-file-upload`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement carries `**Verified by:**` at draft time.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: createFileUploadPlugin returns a Zod-validated PluginDescriptor
|
||||
|
||||
The package SHALL export `createFileUploadPlugin(options): PluginDescriptor<FileUploadPluginConfig>` 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<FileUploadPluginConfig>` 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"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
@@ -0,0 +1,69 @@
|
||||
# Tasks — add-api-plugin-file-upload
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. No retroactive ticking.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/api-plugin-file-upload.md` end-to-end
|
||||
- [x] 1.2 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.3 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.4 Fill in `## Source mapping` — every requirement cites a line range or appears in the "no source line" table
|
||||
- [x] 1.5 Draft `specs/api-plugin-file-upload/spec.md`
|
||||
- [x] 1.6 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 Author `src/error-codes/error-codes.ts` (11 codes + `FileUploadError`)
|
||||
- [x] 2.2 Author `src/types/types.ts` (config + adapter contracts)
|
||||
- [x] 2.3 Author `src/schemas/options.ts` + per-route Zod schemas under `src/schemas/routes/`
|
||||
- [x] 2.4 Author `src/otel/instruments.ts` (4 counters + 1 histogram)
|
||||
- [x] 2.5 Author `src/storage/memory.ts` + `src/storage/file-system.ts`
|
||||
- [x] 2.6 Author `src/session-store/memory.ts`
|
||||
- [x] 2.7 Author `src/validators/mime.ts` + `src/validators/size.ts`
|
||||
- [x] 2.8 Author `src/assembler/assembler.ts` (SHA-256 streaming hasher + chunk concat)
|
||||
- [x] 2.9 Author `src/services/file-upload-service.ts` + `src/services/registry-augmentation.ts`
|
||||
- [x] 2.10 Author `src/routes/build-routes.ts` + per-route handlers
|
||||
- [x] 2.11 Author `src/middleware/context-augmentation.ts` (tenant optional)
|
||||
- [x] 2.12 Author `src/plugin/plugin.ts` (`createFileUploadPlugin` factory + `onInit` lifecycle)
|
||||
- [x] 2.13 Author `src/index.ts` barrel (explicit named exports)
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 Unit tests at `src/<feature>/<feature>.test.ts` covering every scenario
|
||||
- [x] 3.2 Spec-conformance tests at `src/<feature>/<feature>.spec-conformance.test.ts` pinning code-registry / type-level scenarios
|
||||
- [x] 3.3 Integration tests at `tests/integration/full-flow.test.ts` covering all 6 routes (single-shot, chunked, resume, abort, isolation)
|
||||
- [x] 3.4 Fast-check property test at `tests/property.test.ts`
|
||||
- [x] 3.5 `tests/dep-graph.test.ts` invariants
|
||||
- [x] 3.6 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for async)
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, API reference)
|
||||
- [x] 4.2 `README.md` linking every co-located `.md`
|
||||
- [x] 4.3 No `CLAUDE.md` listing update needed — package not in foundation list
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 5.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info across the touched files
|
||||
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-file-upload --force` — green
|
||||
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-plugin-file-upload` — 100/100/100/100
|
||||
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-file-upload` — exit 0
|
||||
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/api-plugin-file-upload --force` — green
|
||||
- [ ] 5.6 `bun scripts/lint/check-test-runner-imports.ts` — exit 0
|
||||
|
||||
## 6. Pre-archive 9-row checklist (skill §7)
|
||||
|
||||
- [ ] 6.1 Generics threaded; `expectTypeOf` assertions on public surface
|
||||
- [ ] 6.2 Zod schemas wired at every declared boundary
|
||||
- [ ] 6.3 Every error code in `error-codes.ts` thrown from a production path
|
||||
- [ ] 6.4 Auth gating tested both on (with principal) and off (anonymous → 401)
|
||||
- [ ] 6.5 OTel instruments emitted from production paths and asserted with real in-memory `MeterProvider`
|
||||
- [ ] 6.6 Resume / dedup tested where claimed (GET status returns chunk indices; idempotent abort)
|
||||
- [ ] 6.7 No retroactive tick
|
||||
- [ ] 6.8 No `export *` in any feature-module barrel
|
||||
- [ ] 6.9 No direct `vitest` / `bun:test` imports
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [ ] 7.1 Fold `specs/api-plugin-file-upload/spec.md` into `openspec/specs/api-plugin-file-upload/spec.md`
|
||||
- [ ] 7.2 Move `openspec/changes/add-api-plugin-file-upload/` → `openspec/archive/2026-05-19-add-api-plugin-file-upload/`
|
||||
Reference in New Issue
Block a user