Files
2026-05-27 23:29:53 +01:00

13 KiB

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 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 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"