13 KiB
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<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>withname === "file-upload"
Scenario: invalid options raise UPLOAD.INVALID_INPUT
- WHEN
createFileUploadPlugin({ basePath: 123 } as never)is called - THEN
FileUploadError(UPLOAD.INVALID_INPUT)is thrown withcause 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
ctxprovides bothappandopenAPIRegistryand the plugin initializes - THEN
registry.getRoutesByPlugin("file-upload")returns 6 entries
Scenario: missing app raises UPLOAD.SERVICE_NOT_AVAILABLE
- WHEN
onInitruns with noappinServiceRegistry - THEN
FileUploadError(UPLOAD.SERVICE_NOT_AVAILABLE)is thrown listingappas 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 uploadIds — 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 throwsAuthError(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 inconfig.deniedMimeTypes(defaults include common executable types) →FileUploadError(UPLOAD.MIME_REJECTED)→ HTTP 415, - the sniffed magic-bytes content type via
@suaveplan/mimedisagrees with the declaredContent-Type→FileUploadError(UPLOAD.MIME_REJECTED)withcontext.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/plainfile - 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)withcontext.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 anUploadSessionand returns{ uploadId, chunkSize, totalChunks }. Body validated byInitiateUploadBodySchemacarryingfileName,totalSize,totalSha256, and optionalmimeType+chunkSize. RejecttotalSize > config.maxFileSizeByteswithUPLOAD.TOO_LARGE.PUT /uploads/{uploadId}/chunks/{chunkIndex}→ accepts raw bytes for the indexed chunk. SHALL raiseUPLOAD.CHUNK_OUT_OF_ORDER(HTTP 409) whenchunkIndex >= totalChunksorchunkIndex < 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-declaredtotalSha256. Mismatch SHALL raiseUPLOAD.HASH_MISMATCH(HTTP 422). Missing chunks raiseUPLOAD.INCOMPLETE(HTTP 409). On success: deletes the session, writes the assembled bytes viaUploadStorage.put, returns the sameSingleShotResponseSchemashape.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
receivedChunkscontaining exactly[0..K]and the client may resume fromK+1
Scenario: hash mismatch on complete
- WHEN the client-declared
totalSha256does not match the assembled SHA-256 - THEN
completeraisesUPLOAD.HASH_MISMATCH(HTTP 422)
Scenario: chunk index out of range
- WHEN
PUT /uploads/{uploadId}/chunks/999is hit on a session withtotalChunks=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 | undefinedwith 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)thenstorage.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
receivedChunksset contains exactly one3
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"}ANDupload.complete_duration_mshave 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.tswalkspackage.jsonexports - 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"