Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
# `assembler.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the chunked-upload assembly + integrity-verification pipeline. `assembleChunks` pulls every chunk from a caller-supplied loader callback, concatenates them in deterministic index order, and bails with `UPLOAD.INCOMPLETE` on the first missing chunk. `verifyHash` then re-derives the SHA-256 of the assembled payload and matches it against the client-declared `totalSha256` so any byte-level corruption (truncated chunk, swapped chunk, in-flight tampering) surfaces as `UPLOAD.HASH_MISMATCH` rather than silently writing a corrupt file.
## Features
- Lazy chunk loader callback so the assembler does not couple to the storage adapter — `FileUploadService` plugs in either a Map lookup (memory) or an `fs.readFile` (file-system) shim.
- Single allocation: `Uint8Array(total)` followed by `set(part, offset)` per chunk for memory efficiency on large uploads.
- Hash comparison is case-insensitive against the expected value but the result returned uses the lowercase canonical form.
- Both helpers throw `FileUploadError` with the right HTTP status code on `context.statusCode` so the unified error handler does not need a per-helper mapping.
## Usage: Basic
```ts
import { assembleChunks, verifyHash } from "@suaveplan/api-plugin-file-upload/assembler/assembler";
const bytes = await assembleChunks({
uploadId: "u1",
totalChunks: 3,
chunkLoader: async (i) => chunks.get(i),
});
const sha = verifyHash({
uploadId: "u1",
bytes,
expectedSha256: declaredHash,
});
```
## Usage: Advanced
```ts
import { assembleChunks, verifyHash, FileUploadError } from "@suaveplan/api-plugin-file-upload";
import { readFile } from "node:fs/promises";
try {
const bytes = await assembleChunks({
uploadId,
totalChunks: session.totalChunks,
chunkLoader: async (i) => {
try {
return new Uint8Array(await readFile(`${dir}/${uploadId}/${i}`));
} catch {
return undefined;
}
},
});
verifyHash({ uploadId, bytes, expectedSha256: session.totalSha256 });
} catch (err) {
if (err instanceof FileUploadError && err.uploadCode === "UPLOAD.INCOMPLETE") {
return c.json({ retry: true }, 409);
}
throw err;
}
```
## API Reference
- `sha256Hex(bytes: Uint8Array): string` — hex-encoded SHA-256.
- `assembleChunks(args: { uploadId, totalChunks, chunkLoader }): Promise<Uint8Array>` — throws `UPLOAD.INCOMPLETE` (409) on first missing chunk.
- `verifyHash(args: { uploadId, bytes, expectedSha256 }): string` — throws `UPLOAD.HASH_MISMATCH` (422) on mismatch; returns the computed hash on success.
## Implementation Notes
- The chunk loader returns `Uint8Array | undefined` so callers signal absence by `undefined` rather than throwing — this keeps the loader callable from inside `Promise.all` if a future optimisation wants parallel reads (current impl is sequential to bound memory).
- Hash comparison normalises the expected value to lowercase so client-side casing variations do not cause spurious mismatches.
- The single-allocation concat avoids the `Buffer.concat` overhead and works in browser bundles when the package is later split, even though current consumers are server-only.

View File

@@ -0,0 +1,67 @@
# `error-codes.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry for `@suaveplan/api-plugin-file-upload`. Each failure path in the plugin throws a `FileUploadError` carrying one of 11 `UPLOAD.*` codes; consumers branch on `error.code` to render localised messages, decide on retry behaviour, or surface user-facing copy. The registry is the single source of truth for every observable failure mode the plugin exposes — the spec, the integration tests, and downstream consumer code all reference it by name.
## Features
- 11 codes registered via `defineCodes("UPLOAD", [...])` so the cause chain, OTel span events, and i18n keys all share the same string prefix.
- `FileUploadError` extends `SuaveplanError<UploadErrorCode>` for typed `instanceof` narrowing.
- Public constructor so call sites in routes, the assembler, and the storage adapters can throw directly without a factory helper.
- Documented per-code semantics so reviewers can confirm every `### Requirement` in the spec maps to exactly one production call site.
## Usage: Basic
```ts
import {
FileUploadError,
UPLOAD_CODES,
} from "@suaveplan/api-plugin-file-upload/error-codes/error-codes";
if (size > limit) {
throw new FileUploadError(
UPLOAD_CODES.TOO_LARGE,
`Upload exceeds singleShotLimitBytes`,
{ context: { statusCode: 413, size, limit } }
);
}
```
## Usage: Advanced
```ts
import { FileUploadError } from "@suaveplan/api-plugin-file-upload";
try {
await uploadService.complete(id, declaredHash, principalId);
} catch (err) {
if (err instanceof FileUploadError) {
switch (err.uploadCode) {
case "UPLOAD.HASH_MISMATCH":
return c.json({ retry: true, reason: "hash-mismatch" }, 422);
case "UPLOAD.INCOMPLETE":
return c.json({ retry: true, reason: "missing-chunks" }, 409);
default:
throw err;
}
}
throw err;
}
```
## API Reference
- `UPLOAD_CODES` — readonly registry of 11 string constants prefixed `UPLOAD.`.
- `UploadErrorCode` — union type derived from `UPLOAD_CODES` values.
- `FileUploadError extends SuaveplanError<UploadErrorCode>` — concrete error class. Constructor signature `(code: UploadErrorCode, message: string, options?: SuaveplanErrorOptions)`. Carries an `uploadCode` field mirroring `code` for ergonomic narrowing.
## Implementation Notes
- The registry is intentionally closed at 11 codes; adding a new code requires bumping the spec delta and threading the new code through a real production path. This is enforced by `error-codes.spec-conformance.test.ts`.
- `cause` carries the original `ZodError` for `INVALID_INPUT` and the underlying `Error` for `STORAGE_FAILURE`. Consumers may walk the cause chain via `SuaveplanError.traverseCauses()` from `@suaveplan/error`.
- `context.statusCode` is set by every throw site so the `@suaveplan/api` error-handler translates it to the right HTTP status without per-code mapping at the handler layer.

View File

@@ -0,0 +1,64 @@
# `instruments.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the OpenTelemetry instrument bundle for `@suaveplan/api-plugin-file-upload`: four counters and one histogram. Every public route handler and the underlying `FileUploadService` reach for these instruments and emit on every success/failure path, so operators get a uniform observability surface without bespoke per-handler boilerplate.
## Features
- Lazy bind: `getUploadInstruments()` resolves against the global `MeterProvider` exactly once and caches the result.
- `resetUploadInstruments()` clears the cache so test setup that installs a fresh provider can force re-bind.
- Outcome labels (`success` / `rejected` / `error` / `accepted` / `duplicate`) are documented in the JSDoc so dashboards have stable label semantics.
- `upload.bytes_total` uses the OTel `By` unit so Prometheus scrapers can derive throughput correctly.
## Usage: Basic
```ts
import { getUploadInstruments } from "@suaveplan/api-plugin-file-upload/otel/instruments";
const m = getUploadInstruments();
m.singleShotTotal.add(1, { outcome: "success" });
m.bytesTotal.add(byteLength);
```
## Usage: Advanced
```ts
import {
getUploadInstruments,
resetUploadInstruments,
} from "@suaveplan/api-plugin-file-upload";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
beforeEach(() => {
metrics.setGlobalMeterProvider(new MeterProvider());
resetUploadInstruments();
});
it("complete emits counter and histogram", async () => {
const m = getUploadInstruments();
const start = performance.now();
/* ... */
m.completesTotal.add(1, { outcome: "success" });
m.completeDurationMs.record(performance.now() - start, {
outcome: "success",
});
});
```
## API Reference
- `UploadInstruments` — shape of the bundle: `singleShotTotal`, `chunksTotal`, `completesTotal`, `bytesTotal` (all `Counter`), `completeDurationMs` (`Histogram`).
- `getUploadInstruments(): UploadInstruments` — returns the cached bundle.
- `resetUploadInstruments(): void` — clears the cache.
## Implementation Notes
- The meter name `@suaveplan/api-plugin-file-upload` doubles as the OTel scope so traces correlate with the package origin.
- All counters share the same `outcome` label vocabulary so dashboards can apply a single label-mapping rule across the bundle.
- The histogram unit is `ms` rather than `s` because every other timing instrument in Suaveplan standardised on `ms` for sub-second resolution at the dashboard layer.

View File

@@ -0,0 +1,58 @@
# `plugin.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Authoritative `createFileUploadPlugin` factory. Wraps the resolved configuration in a `PluginDescriptor<FileUploadPluginConfig>` and executes a 6-step `onInit` lifecycle: resolve required `app` service → construct default storage + session-store → build the `FileUploadService` and register it in the host `ServiceRegistry` → install `requireAuth()` on `basePath/*` → register the 6 OpenAPI routes via `definePluginRoutes` → schedule the cleanup-expired interval. The descriptor's `onClose` clears the interval so plugin teardown leaves no dangling timers.
## Features
- Zod-validated factory boundary — invalid options throw `FileUploadError(UPLOAD.INVALID_INPUT)` carrying the `ZodError` as `cause`.
- Default storage (`MemoryUploadStorage`) and session store (`MemoryUploadSessionStore`) when none supplied; production callers swap in `FileSystemUploadStorage` or a Surreal-backed adapter via options.
- Cleanup interval auto-tuned to `sessionTtlMs / 4` (minimum 1 s) so expired sessions are reclaimed within one quarter-TTL of expiry.
- `requireAuth()` middleware installed on the configured `basePath/*` so every route is principal-gated without per-handler boilerplate.
- Falls back to direct Hono mounting (and logs a warning) when no `OpenAPIRegistry` is in the host `ServiceRegistry`.
- `setInterval` handle is `unref`'d so plugin lifecycle does not pin the event loop after `onClose`.
## Usage: Basic
```ts
import { createFileUploadPlugin } from "@suaveplan/api-plugin-file-upload";
const plugin = createFileUploadPlugin();
await pluginRegistry.register(plugin);
```
## Usage: Advanced
```ts
import {
createFileUploadPlugin,
FileSystemUploadStorage,
} from "@suaveplan/api-plugin-file-upload";
const plugin = createFileUploadPlugin({
basePath: "/api/v1/uploads",
singleShotLimitBytes: 32 * 1024 * 1024,
maxFileSizeBytes: 4 * 1024 * 1024 * 1024,
defaultChunkSize: 8 * 1024 * 1024,
sessionTtlMs: 6 * 60 * 60 * 1000,
allowedMimeTypes: ["image/png", "image/jpeg", "application/pdf"],
storage: new FileSystemUploadStorage({ rootDir: "/var/lib/uploads" }),
});
await pluginRegistry.register(plugin);
```
## API Reference
- `createFileUploadPlugin(options?: FileUploadPluginOptions): PluginDescriptor<FileUploadPluginConfig>` — see file-level JSDoc for the full lifecycle.
## Implementation Notes
- The Zod schema validates structurally — a misshapen `storage` or `sessionStore` value is rejected before the descriptor is constructed, not at first request time.
- The route-rebase step rewrites every route entry's `path` to include `basePath` so `definePluginRoutes` and the served OpenAPI doc share a single ground truth.
- `requireAuth()` is imported from `@suaveplan/api-plugin-auth` so the plugin gains the same anonymous→401 contract every other auth-aware plugin uses.
- `cleanupExpired` is invoked with the current wall clock; if a test injects a `clock` override into `createFileUploadService` directly, the interval should be paused (tests do not exercise the scheduler).

View File

@@ -0,0 +1,51 @@
# `build-routes.ts`
## Purpose
Builds the 6 OpenAPI route entries the plugin mounts on the host Hono app: single-shot `POST /`, chunked-init `POST /initiate`, chunk append `PUT /{uploadId}/chunks/{chunkIndex}`, finalise `POST /{uploadId}/complete`, abort `DELETE /{uploadId}`, and status `GET /{uploadId}`. Each route uses `RouteBuilder` from `@suaveplan/api-openapi` so request/response schemas flow through end-to-end, keeping the served `/openapi.json` document accurate against the live wire surface.
## Features
- Six routes registered with typed request bodies, params, and responses.
- Single shared service handle (`FileUploadService`) wired into every handler, so the routes do not own any per-request state.
- Reuses the same `SingleShotResponseSchema` across `POST /` and `POST /{uploadId}/complete` so clients have a uniform success shape regardless of which upload mode they used.
## Usage: Basic
```ts
import { buildUploadRoutes } from "@suaveplan/api-plugin-file-upload/routes/routes";
import { definePluginRoutes } from "@suaveplan/api-openapi";
definePluginRoutes(buildUploadRoutes(service), {
app,
registry: openAPIRegistry,
pluginName: "file-upload",
});
```
## Usage: Advanced
```ts
import { buildUploadRoutes } from "@suaveplan/api-plugin-file-upload";
const entries = buildUploadRoutes(service);
for (const entry of entries) {
console.log(entry.route.method, entry.route.path);
}
// → post /
// → post /initiate
// → put /{uploadId}/chunks/{chunkIndex}
// → post /{uploadId}/complete
// → delete /{uploadId}
// → get /{uploadId}
```
## API Reference
- `buildUploadRoutes(service: FileUploadService): PluginRouteEntry[]` — returns the 6 ready-to-mount entries.
## Implementation Notes
- The paths use OpenAPI `{name}` placeholders (not Hono's `:name`) because `RouteBuilder` emits OpenAPI 3.1 path templates. `@hono/zod-openapi` translates back to Hono's parameter syntax at registration.
- The single-shot route uses `POST /` — Hono mounts it under `basePath`, so the final URL is `POST {basePath}` (default `POST /uploads`).
- All routes return JSON. Multipart parsing for the single-shot path happens INSIDE the handler so the route table stays declarative.

View File

@@ -0,0 +1,49 @@
# `handlers.ts`
## Purpose
Per-route Hono handler implementations consumed by `buildUploadRoutes`. Each handler reads the auth principal from `c.get("user")`, optionally narrows to a tenant via `c.get("tenant")`, parses the request body / params with the per-route Zod schemas, delegates to `FileUploadService`, and projects the service's `UploadSession`/`UploadRecord` return value onto the wire response shape.
## Features
- Single `readScope(c)` helper produces a `PrincipalScope` from the augmented Hono context. Missing principal raises `UPLOAD.INVALID_INPUT` (the sibling `requireAuth()` middleware should have already replied 401, so reaching this path indicates plugin misconfiguration).
- `toStatus` projects either an `UploadSession` (in-progress) or an `UploadRecord` (complete) onto the uniform `UploadStatusResponse` shape so the `GET` route serves both phases through a single schema.
- Multipart parsing for `POST /uploads` reads via `c.req.parseBody()`; chunk PUT reads via `c.req.raw.arrayBuffer()` so binary payloads are preserved byte-for-byte.
## Usage: Basic
```ts
import { buildHandlers } from "@suaveplan/api-plugin-file-upload";
const handlers = buildHandlers(service);
// `handlers.singleShot`, `handlers.initiate`, …, `handlers.status`
```
## Usage: Advanced
```ts
import { buildHandlers, FileUploadError } from "@suaveplan/api-plugin-file-upload";
const handlers = buildHandlers(service);
app.post("/uploads", async (c) => {
try {
return await handlers.singleShot(c);
} catch (err) {
if (err instanceof FileUploadError && err.uploadCode === "UPLOAD.TOO_LARGE") {
return c.json({ retry: false }, 413);
}
throw err;
}
});
```
## API Reference
- `buildHandlers(service: FileUploadService): { singleShot, initiate, chunk, complete, abort, status }` — each value is a `(c: Context) => Promise<Response>`.
## Implementation Notes
- The `readScope` helper checks for `null` and `undefined` so the same code runs whether or not the host installed `api-plugin-auth` (e.g. for integration tests that synthesise a principal directly).
- `parseBody` may throw if the client did not send multipart — we translate that to `UPLOAD.INVALID_INPUT` (400) rather than letting a raw runtime error escape.
- The chunk PUT handler does NOT cap chunk size at the handler layer because the assembler+service already enforce the session's `chunkSize` on `complete` time (a mismatched chunk would fail hash verification).
- The abort handler returns a static `state: "complete"` response shape for idempotency — calling abort twice returns success both times.

View File

@@ -0,0 +1,51 @@
# `options.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the Zod schema validating caller-supplied `FileUploadPluginOptions`. Invalid input — wrong type, malformed `basePath`, non-positive limits, non-conforming `storage`/`sessionStore` shapes — throws `FileUploadError(UPLOAD.INVALID_INPUT)` at the `createFileUploadPlugin` boundary so misconfiguration fails fast at boot rather than at first request.
## Features
- `basePath` regex matches Hono's mount rules: starts with `/`, never ends with `/` except the root.
- Default `singleShotLimitBytes` of 10 MiB, `maxFileSizeBytes` of 1 GiB, `defaultChunkSize` of 5 MiB, `sessionTtlMs` of 24 h.
- Default MIME denylist covers common native-executable formats so a default-configured deployment refuses obvious malware delivery vectors.
- `storage`, `sessionStore`, and `logger` validated structurally via `z.custom` (they are runtime objects).
- Exports both the input shape (what callers pass) and the resolved shape (what the descriptor stores).
## Usage: Basic
```ts
import { FileUploadPluginOptionsSchema } from "@suaveplan/api-plugin-file-upload/schemas/options";
const config = FileUploadPluginOptionsSchema.parse({ basePath: "/files" });
```
## Usage: Advanced
```ts
import { FileUploadPluginOptionsSchema } from "@suaveplan/api-plugin-file-upload";
import { MemoryUploadStorage } from "@suaveplan/api-plugin-file-upload/storage/memory";
const config = FileUploadPluginOptionsSchema.parse({
basePath: "/api/v1/uploads",
singleShotLimitBytes: 5 * 1024 * 1024,
allowedMimeTypes: ["image/png", "image/jpeg", "application/pdf"],
storage: new MemoryUploadStorage(),
});
```
## API Reference
- `FileUploadPluginOptionsSchema` — Zod object schema.
- `FileUploadPluginOptionsInput` — `z.input` type, what callers may pass.
- `FileUploadPluginOptionsResolved` — `z.output` type, post-default shape.
## Implementation Notes
- The schema applies defaults rather than rejecting missing fields — calling `createFileUploadPlugin({})` is a valid (development) configuration. The defaults match the hardening baseline (executable denylist on, generous size caps, 24 h TTL).
- `allowedMimeTypes` empty defaults to "no allow-list" (everything except the denylist). Setting any value flips to "exclusive allow" semantics — the validator rejects anything not in the array.
- The `z.custom` structural checks for `storage`/`sessionStore`/`logger` accept any object exposing the expected duck-typed methods; full interface conformance is enforced at the consumer call site by TypeScript when the package's exported types are imported.

View File

@@ -0,0 +1,50 @@
# `initiate.ts`
## Purpose
Defines the request body and response shape for `POST /uploads/initiate`, the chunked upload bootstrap endpoint. The client declares the file's total size, SHA-256 hash, and optional MIME type up-front; the server returns the `uploadId`, server-chosen `chunkSize`, and `totalChunks` so the client can deterministically slice the file.
## Features
- Client-declared `totalSha256` is the contract the server later verifies on `complete` — mismatch raises `UPLOAD.HASH_MISMATCH` (HTTP 422).
- `mimeType` defaults to `application/octet-stream` so clients uploading binary payloads need not guess.
- `chunkSize` may be overridden by the client; absent, the server uses the configured `defaultChunkSize`.
- 255-char `fileName` cap matches POSIX `NAME_MAX`.
## Usage: Basic
```ts
import { InitiateUploadBodySchema } from "@suaveplan/api-plugin-file-upload/schemas/routes";
const body = InitiateUploadBodySchema.parse(await c.req.json());
```
## Usage: Advanced
```ts
import { InitiateUploadBodySchema } from "@suaveplan/api-plugin-file-upload";
import fc from "fast-check";
fc.assert(
fc.property(
fc.record({
fileName: fc.string({ minLength: 1, maxLength: 255 }),
totalSize: fc.integer({ min: 1, max: 1024 * 1024 * 1024 }),
totalSha256: fc.hexaString({ minLength: 64, maxLength: 64 }),
}),
(input) => InitiateUploadBodySchema.safeParse(input).success
),
{ numRuns: 200 }
);
```
## API Reference
- `InitiateUploadBodySchema` — Zod schema for the request body.
- `InitiateUploadResponseSchema` — Zod schema for the success response (`uploadId`, `chunkSize`, `totalChunks`).
- `InitiateUploadBody` / `InitiateUploadResponse` — `z.infer` aliases.
## Implementation Notes
- The server intentionally trusts the client-declared `totalSha256` only as a verification target — the actual hash is computed server-side from the assembled bytes. A mismatch surfaces as a 422 so clients can re-derive and retry without rebuilding the entire upload state.
- `chunkSize` is server-bounded by `defaultChunkSize` to prevent pathological tiny-chunk attacks; the validator at the service layer caps client-supplied values.

View File

@@ -0,0 +1,44 @@
# `single-shot.ts`
## Purpose
Defines the typed JSON response returned by both `POST /uploads` (single-shot) and `POST /uploads/{uploadId}/complete` (chunked finalisation). Centralising the response shape keeps the OpenAPI doc in lockstep across the two surfaces — clients written against `POST /uploads` work unchanged against the chunked `complete` route.
## Features
- 64-char SHA-256 length constraint catches obvious truncation bugs at the schema boundary.
- Non-negative `size` so a zero-byte upload still validates cleanly.
- `mimeType` is required because the validator always populates it (declared or sniffed).
## Usage: Basic
```ts
import { SingleShotResponseSchema } from "@suaveplan/api-plugin-file-upload/schemas/routes";
const parsed = SingleShotResponseSchema.parse(await response.json());
console.log(parsed.uploadId, parsed.sha256);
```
## Usage: Advanced
```ts
import {
SingleShotResponseSchema,
type SingleShotResponse,
} from "@suaveplan/api-plugin-file-upload";
function describeUpload(rec: SingleShotResponse): string {
return `Uploaded ${rec.fileName} (${rec.size} bytes, sha256=${rec.sha256.slice(0, 8)}…)`;
}
const ok: SingleShotResponse = SingleShotResponseSchema.parse(json);
```
## API Reference
- `SingleShotResponseSchema` — Zod object schema.
- `SingleShotResponse` — `z.infer<typeof SingleShotResponseSchema>` type alias.
## Implementation Notes
- Reusing the same shape across two routes is a deliberate ergonomic choice: callers that switch from a small single-shot file to a large chunked upload do not have to adjust their response parsing.
- The schema omits `storageKey` and `principalId` from the response so internal storage layout and ownership metadata never leak through the wire surface.

View File

@@ -0,0 +1,60 @@
# `status.ts`
## Purpose
Defines the path-parameter and response shapes for the remaining chunked-upload routes: `PUT /uploads/{uploadId}/chunks/{chunkIndex}`, `POST /uploads/{uploadId}/complete`, `GET /uploads/{uploadId}`, and `DELETE /uploads/{uploadId}`. The status response is the resume contract: clients call `GET` after a crash and pick up from the next missing chunk.
## Features
- `ChunkParamsSchema` constrains `chunkIndex` to a non-negative integer via regex — invalid values reject at the boundary so the handler never sees malformed paths.
- `CompleteUploadBodySchema` allows an optional `totalSha256` override for clients that recomputed the hash mid-flight.
- `UploadStatusResponseSchema` exposes a discriminated `state` literal so consumers can switch on `"in-progress"` vs `"complete"` without ambiguity.
## Usage: Basic
```ts
import { UploadStatusResponseSchema } from "@suaveplan/api-plugin-file-upload/schemas/routes";
const status = UploadStatusResponseSchema.parse(await response.json());
const missing: number[] = [];
for (let i = 0; i < status.totalChunks; i++) {
if (!status.receivedChunks.includes(i)) missing.push(i);
}
```
## Usage: Advanced
```ts
import {
ChunkParamsSchema,
UploadStatusResponseSchema,
} from "@suaveplan/api-plugin-file-upload";
async function resume(uploadId: string, fetchFn: typeof fetch) {
const status = UploadStatusResponseSchema.parse(
await (await fetchFn(`/uploads/${uploadId}`)).json()
);
if (status.state === "complete") return;
for (let i = 0; i < status.totalChunks; i++) {
if (status.receivedChunks.includes(i)) continue;
const params = ChunkParamsSchema.parse({
uploadId,
chunkIndex: String(i),
});
await uploadChunk(params, sliceFor(i));
}
}
```
## API Reference
- `UploadIdParamsSchema` — path parameter `{ uploadId }`.
- `ChunkParamsSchema` — path parameter `{ uploadId, chunkIndex }`.
- `CompleteUploadBodySchema` — optional body with `totalSha256` override; defaults to `{}` when omitted.
- `UploadStatusResponseSchema` — `{ uploadId, totalChunks, receivedChunks, state }`.
- `AppendChunkResponseSchema` — `{ uploadId, chunkIndex, receivedChunks }` (the count, not the set).
## Implementation Notes
- `chunkIndex` is a string in the params (URL paths are strings) and parsed to a number in the handler — the schema enforces only that the path segment is decimal-digit-only.
- The status response returns `receivedChunks` as an array, not a set, so it serialises cleanly through JSON; clients reconstruct the set if needed.

View File

@@ -0,0 +1,54 @@
# `complete-chunked.ts`
## Purpose
Internal back-end implementing the `complete` operation of `FileUploadService`. Extracted from the main service file so each module stays under the suaveplan 300-line budget. The pipeline is: assemble every chunk in `[0, totalChunks)` order → verify SHA-256 against the declared expected hash (or an explicit override) → write the assembled bytes to the canonical record key → save the `UploadRecord` → garbage-collect per-chunk storage entries → clear the in-flight session.
## Features
- Single-pass assembly via the `assembler` helper so chunk ordering and missing-chunk detection live in one place.
- Override-on-complete lets clients adjust the declared hash if they recomputed mid-flight without rebuilding the entire session.
- Emits both the outcome counter (`upload.completes_total{outcome}`) and the duration histogram (`upload.complete_duration_ms{outcome}`) on every code path so dashboards can correlate failure rate with latency.
- Cleanup runs even on success (chunk-side storage entries are deleted) so the storage adapter never accumulates orphaned chunk keys.
## Usage: Basic
```ts
import { runComplete } from "@suaveplan/api-plugin-file-upload";
const record = await runComplete(session, undefined, {
storage,
sessionStore,
clock: () => Date.now(),
});
```
## Usage: Advanced
```ts
import { runComplete, FileUploadError } from "@suaveplan/api-plugin-file-upload";
try {
await runComplete(session, clientRecomputedHash, opts);
} catch (err) {
if (err instanceof FileUploadError) {
switch (err.uploadCode) {
case "UPLOAD.INCOMPLETE":
return c.json({ retry: true, reason: "missing-chunks" }, 409);
case "UPLOAD.HASH_MISMATCH":
return c.json({ retry: true, reason: "hash-mismatch" }, 422);
}
}
throw err;
}
```
## API Reference
- `RunCompleteOptions` — `{ storage, sessionStore, clock }`.
- `runComplete(session, overrideSha256?, opts): Promise<UploadRecord>` — see Purpose for pipeline order; throws `UPLOAD.INCOMPLETE`, `UPLOAD.HASH_MISMATCH`, or `UPLOAD.STORAGE_FAILURE`.
## Implementation Notes
- The chunk deletion loop runs sequentially. A parallel `Promise.all` is tempting but most storage adapters (`FileSystemUploadStorage` in particular) prefer FIFO syscalls to avoid file-descriptor exhaustion on very large uploads.
- The histogram is recorded INSIDE the `try` so even early failures (e.g. an assemble that throws on the second chunk) still contribute to the observed-failure-latency picture.

View File

@@ -0,0 +1,57 @@
# `file-upload-helpers.ts`
## Purpose
Internal helpers shared by `file-upload-service.ts`: the storage-key naming convention (`CHUNK_KEY`, `RECORD_KEY`) and the `assertOwnership` cross-principal guard. Extracted into its own module so the main service file stays under the per-file line budget, and so the ownership rule is testable in isolation.
## Features
- `CHUNK_KEY` / `RECORD_KEY` centralise the storage-key format so future migrations can change the prefix in one place.
- `assertOwnership` enforces the anti-enumeration rule: foreign uploadIds raise `UPLOAD.NOT_FOUND` (not `FORBIDDEN`).
- `PrincipalScope` is the minimal projection the service needs from the auth principal — `principalId` + optional `tenantId`.
## Usage: Basic
```ts
import {
assertOwnership,
CHUNK_KEY,
} from "@suaveplan/api-plugin-file-upload";
const key = CHUNK_KEY("u1", 3);
assertOwnership(
{ principalId: "alice" },
{ principalId: "alice" },
"u1"
);
```
## Usage: Advanced
```ts
import { assertOwnership, FileUploadError } from "@suaveplan/api-plugin-file-upload";
try {
assertOwnership(
{ principalId: "alice" },
{ principalId: "bob" },
"u1"
);
} catch (err) {
if (err instanceof FileUploadError && err.uploadCode === "UPLOAD.NOT_FOUND") {
// 404 surfaced — anti-enumeration enforced
}
}
```
## API Reference
- `CHUNK_KEY(uploadId: string, idx: number): string` — returns `session-${uploadId}-chunk-${idx}`.
- `RECORD_KEY(uploadId: string): string` — returns `record-${uploadId}`.
- `PrincipalScope` — `{ principalId: string; tenantId?: string }`.
- `assertOwnership(owner, record, uploadId): void` — throws `UPLOAD.NOT_FOUND` (404) on mismatch.
## Implementation Notes
- The "missing tenant on both sides" case is normalised via `?? undefined` so undefined and absent compare equal. Two records with the same `principalId` but mismatched tenant IDs are still considered foreign (multi-tenant isolation is enforced at the same boundary as user isolation).
- The 404-vs-403 choice is deliberate: a 403 leaks the existence of the upload to a different principal. Tests pin this behaviour.

View File

@@ -0,0 +1,68 @@
# `file-upload-service.ts`
## Purpose
Canonical `FileUploadService` factory consumed by the plugin's route handlers and registered as the typed `fileUploadService` slot in the host's `ServiceRegistry`. It is the single integration point between the HTTP surface and the storage/session/validation primitives shipped in this package — siblings that need programmatic upload access call `ctx.getService("fileUploadService")` instead of replaying multipart parsing or chunk bookkeeping.
## Features
- 7-method surface: `initiate`, `appendChunk`, `complete`, `abort`, `get`, `singleShot`, `cleanupExpired`.
- Pluggable `idGenerator` + `clock` so deterministic tests are first-class (defaults to `randomUUID` + `Date.now`).
- Cross-principal isolation built into every lookup via `assertOwnership` — foreign IDs raise `UPLOAD.NOT_FOUND` (404, anti-enumeration).
- TTL enforcement on every chunked-session interaction — expired sessions reject with `UPLOAD.SESSION_EXPIRED` (410).
- Idempotent chunk uploads — re-uploading an already-received chunk is a no-op (and emits the `duplicate` counter outcome).
- `cleanupExpired(at)` returns the number of sessions reclaimed so the plugin's interval scheduler can log throughput.
## Usage: Basic
```ts
import { createFileUploadService, MemoryUploadSessionStore, MemoryUploadStorage } from "@suaveplan/api-plugin-file-upload";
const svc = createFileUploadService({
config: resolvedConfig,
storage: new MemoryUploadStorage(),
sessionStore: new MemoryUploadSessionStore(),
});
const record = await svc.singleShot({
principalId: "alice",
fileName: "hello.txt",
bytes: new TextEncoder().encode("hi"),
declaredMimeType: "text/plain",
});
```
## Usage: Advanced
```ts
import { createFileUploadService, FileSystemUploadStorage } from "@suaveplan/api-plugin-file-upload";
const svc = createFileUploadService({
config,
storage: new FileSystemUploadStorage({ rootDir: "/var/lib/uploads" }),
sessionStore: new MemoryUploadSessionStore(),
idGenerator: () => crypto.randomUUID(),
clock: () => Date.now(),
});
const session = await svc.initiate({ principalId, fileName, totalSize, totalSha256, mimeType: "application/octet-stream" });
for (let i = 0; i < session.totalChunks; i++) {
await svc.appendChunk(session.uploadId, i, sliceFor(i), { principalId });
}
const record = await svc.complete(session.uploadId, { principalId });
```
## API Reference
- `FileUploadService` — interface with `initiate`/`appendChunk`/`complete`/`abort`/`get`/`singleShot`/`cleanupExpired`.
- `InitiateInput` — `{ principalId, tenantId?, fileName, totalSize, totalSha256, mimeType, chunkSize? }`.
- `SingleShotInput` — re-exported from `./single-shot`.
- `PrincipalScope` — re-exported from `./file-upload-helpers`.
- `CreateFileUploadServiceOptions` — `{ config, storage, sessionStore, idGenerator?, clock? }`.
- `createFileUploadService(opts): FileUploadService`.
## Implementation Notes
- The complete and single-shot paths delegate to `complete-chunked.ts` and `single-shot.ts` respectively so each file stays under the 300-line suaveplan budget.
- `ensureSession` is a single funnel that performs lookup + ownership check + TTL check. Every method that touches an in-flight session goes through it.
- `notFound` is a `never`-returning helper so TypeScript narrows the caller's binding without an explicit `if` after the throw.
- `get` returns `UploadSession | UploadRecord` — callers use a `state` discriminator (computed by the route handler) to fork rendering.
- `abort` is idempotent: missing sessions and missing records BOTH return cleanly (after the ownership check on a record, if one exists).

View File

@@ -0,0 +1,43 @@
# `registry-augmentation.ts`
## Purpose
Augments the cross-package `ServiceRegistry` interface declared in `@suaveplan/api-plugin/service-registry` so consumers of `@suaveplan/api-plugin-file-upload` see `ctx.getService("fileUploadService")` resolve to `FileUploadService | undefined` without an explicit cast. The file is import-for-side-effects only: it adds the new slot AND re-declares the host-side slots (`app`, `openAPIRegistry`, `logger`, `eventBus`) so plugin unit tests work in isolation without depending on the full `@suaveplan/api` augmentation chain.
## Features
- Single `declare module` block — TypeScript merges with the parent interface; no runtime code.
- Re-declares the host slots so the plugin's `onInit(ctx)` resolves `app` / `openAPIRegistry` with full typing even outside the `@suaveplan/api` boot path.
- Side-effect import from `src/index.ts` ensures every consumer of the package gets the typing without an explicit subpath import.
## Usage: Basic
```ts
import "@suaveplan/api-plugin-file-upload"; // side-effect: augments ServiceRegistry
// In a sibling plugin's onInit
const upload = ctx.getService("fileUploadService"); // typed
```
## Usage: Advanced
```ts
import "@suaveplan/api-plugin-file-upload/services/file-upload-service";
// Import the augmentation directly when consumers want to defer the
// full root-barrel side-effect (e.g. tree-shaking-sensitive bundles).
```
## API Reference
The module exports no runtime values. It augments `ServiceRegistry` with:
- `fileUploadService: FileUploadService`
- `app: OpenAPIHono` (cumulative)
- `openAPIRegistry: OpenAPIRegistry` (cumulative)
- `logger: LoggerLike` (cumulative)
- `eventBus: EventBusLike` (cumulative)
## Implementation Notes
- The cumulative re-declaration is deliberate. Different plugin packages augment overlapping slots; TypeScript merges those declarations across the dependency graph so any one plugin sees the union.
- The augmentation is `.ts`-only; the build emits an empty `.js` (or trivially a no-op import) because there is no runtime side-effect beyond the type-system contribution.

View File

@@ -0,0 +1,49 @@
# `single-shot.ts`
## Purpose
Internal back-end implementing the `singleShot` operation of `FileUploadService`. Extracted from the main service file to keep each implementation file under the suaveplan 300-line budget while still keeping the responsibility cohesive: size limit → MIME validation (with sniff cross-check) → storage persistence → record save → OTel emission.
## Features
- Encapsulates the full single-shot validation pipeline so the public `FileUploadService.singleShot` is a single function call.
- Reads the first 4 KiB into the MIME sniffer so spoofed Content-Types are rejected without buffering the whole payload twice.
- Counts outcomes (`success` / `rejected` / `error`) onto `upload.singleshot_total` and bytes onto `upload.bytes_total` so dashboards reflect every code path.
- Generic in the id generator and clock so `createFileUploadService` can plug in its own deterministic values for tests.
## Usage: Basic
```ts
import { runSingleShot } from "@suaveplan/api-plugin-file-upload";
const record = await runSingleShot(
{ principalId: "alice", fileName: "hello.txt", bytes, declaredMimeType: "text/plain" },
{ config, storage, sessionStore, idGenerator: () => "u1", clock: () => Date.now() }
);
```
## Usage: Advanced
```ts
import { runSingleShot, FileUploadError } from "@suaveplan/api-plugin-file-upload";
try {
await runSingleShot(input, opts);
} catch (err) {
if (err instanceof FileUploadError && err.uploadCode === "UPLOAD.MIME_REJECTED") {
// spoof or denied — render UI-friendly 415 page
}
throw err;
}
```
## API Reference
- `SingleShotInput` — `{ principalId, tenantId?, fileName, bytes, declaredMimeType }`.
- `RunSingleShotOptions` — `{ config, storage, sessionStore, idGenerator, clock }`.
- `runSingleShot(input, opts): Promise<UploadRecord>` — see Purpose for pipeline order; throws `UPLOAD.TOO_LARGE`, `UPLOAD.MIME_REJECTED`, or `UPLOAD.STORAGE_FAILURE` depending on which guard fires.
## Implementation Notes
- The sniff sample is capped at 4 KiB because every magic-bytes signature the `@suaveplan/mime` registry ships fits well inside that window; sniffing more wastes CPU on tiny files and offers no additional confidence.
- Outcomes distinguish `rejected` (validation-side failure, `FileUploadError`) from `error` (storage/store-side failure, any other Error) so on-call rotations can alert on `error` without being paged for legitimate user rejections.

View File

@@ -0,0 +1,53 @@
# `memory.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
In-memory `UploadSessionStore` adapter. Tracks both in-flight `UploadSession` state (sessions Map) and completed `UploadRecord` metadata (records Map) so `GET /uploads/{uploadId}` works against either an in-progress chunked upload OR a completed single-shot upload uniformly. Used as the default store when `createFileUploadPlugin` is invoked without an explicit `sessionStore` option.
## Features
- Idempotent `appendChunkIndex` via underlying `Set<number>` — calling twice with the same index is a no-op.
- `listExpired(now, ttlMs)` returns every session whose `createdAt + ttlMs <= now` so the plugin's cleanup loop has a single-call sweep API.
- Strict `create` (rejects duplicates) and strict `update` (rejects missing) so logic bugs in the service layer surface immediately rather than silently writing the wrong record.
- Records are stored separately from sessions so `complete` can swap session → record atomically without leaving an in-flight reference behind.
## Usage: Basic
```ts
import { MemoryUploadSessionStore } from "@suaveplan/api-plugin-file-upload/session-store/memory";
const store = new MemoryUploadSessionStore();
await store.create(session);
const fetched = await store.read(session.uploadId);
```
## Usage: Advanced
```ts
import { MemoryUploadSessionStore } from "@suaveplan/api-plugin-file-upload";
const store = new MemoryUploadSessionStore();
await store.create(session);
await store.appendChunkIndex(session.uploadId, 0);
await store.appendChunkIndex(session.uploadId, 0); // idempotent
const expired = await store.listExpired(Date.now() + 60_000, 1000);
for (const s of expired) await store.delete(s.uploadId);
```
## API Reference
- `class MemoryUploadSessionStore implements UploadSessionStore` — constructor takes no arguments.
- `create(session)` / `read(id)` / `update(session)` / `delete(id)` — standard CRUD.
- `appendChunkIndex(id, index): Promise<UploadSession>` — returns the updated session.
- `listExpired(now, ttlMs): Promise<readonly UploadSession[]>` — TTL sweep.
- `readRecord(id)` / `saveRecord(record)` / `deleteRecord(id)` — record-side CRUD.
## Implementation Notes
- The session and record maps are kept distinct because a single upload transitions session → record atomically; conflating them would force the caller to encode that transition with an enum field on a union type.
- `appendChunkIndex` always returns a fresh `UploadSession` value (shallow-copied with the new set) so consumers comparing references can detect updates without `===` foot-guns.
- `delete` is intentionally idempotent — abort routes call it without first checking existence.

View File

@@ -0,0 +1,52 @@
# `file-system.ts`
## Purpose
Production-grade `UploadStorage` adapter backed by `node:fs/promises`. Stores bytes as flat files under a configurable `rootDir`. Every key is validated for path-traversal escape attempts BEFORE touching disk, so a malicious `../etc/passwd` key never reaches the filesystem syscall layer — it raises `FileUploadError(UPLOAD.STORAGE_FAILURE)` from the resolver.
## Features
- Path-traversal hardening: rejects keys containing `/`, `\`, or NUL bytes; resolves keys via `path.resolve` and verifies the result is inside `rootDir`.
- Lazy `mkdir -p rootDir` on first `put` so callers can construct the adapter before the directory exists.
- Atomic `writeFile` on POSIX so partial writes never leave half-files visible to a parallel reader.
- `delete` uses `rm({ force: true })` so absent files do not raise — matching the abort route's idempotent semantics.
- `exists` swallows `stat` errors and returns `false` rather than leaking the underlying syscall error code.
## Usage: Basic
```ts
import { FileSystemUploadStorage } from "@suaveplan/api-plugin-file-upload/storage/file-system";
const storage = new FileSystemUploadStorage({
rootDir: "/var/lib/suaveplan/uploads",
});
await storage.put("upload-001", new Uint8Array([0xff]));
```
## Usage: Advanced
```ts
import { FileSystemUploadStorage, FileUploadError } from "@suaveplan/api-plugin-file-upload";
const storage = new FileSystemUploadStorage({ rootDir: "/tmp/uploads" });
try {
await storage.put("../etc/passwd", new Uint8Array([0]));
} catch (err) {
if (err instanceof FileUploadError && err.uploadCode === "UPLOAD.STORAGE_FAILURE") {
console.warn("path-traversal rejected:", err.message);
}
}
```
## API Reference
- `FileSystemUploadStorageOptions` — `{ rootDir: string }`.
- `class FileSystemUploadStorage implements UploadStorage` — constructor `(options: FileSystemUploadStorageOptions)`.
- `put(key, bytes)` / `get(key)` / `delete(key)` / `exists(key)` — same shape as `UploadStorage`.
## Implementation Notes
- The path-traversal check rejects ANY separator in the raw key, then resolves and re-verifies the resolved path is inside `rootDir`. The double check defends against both crafted strings AND quirky platform path normalisation.
- `readFile` returns a `Buffer`; the wrapper re-wraps as `Uint8Array` so callers see a tier-neutral byte view.
- Storage failures preserve the underlying `cause` so operators can trace EACCES / ENOSPC at the OTel log layer without losing context.

View File

@@ -0,0 +1,51 @@
# `memory.ts`
## Purpose
In-memory `UploadStorage` adapter. Stores raw bytes in a process-local `Map<string, Uint8Array>` for fast unit tests and local development. Documented as test-only because every byte vanishes when the process exits; production code MUST swap in `FileSystemUploadStorage` (or a sibling S3/blob adapter).
## Features
- Implements the full `UploadStorage` contract (`put`/`get`/`delete`/`exists`).
- Defensive copy on `put` so the caller mutating the input buffer does not affect stored bytes.
- `snapshot()` test helper returns the current key set without forcing tests through `get()`.
- Throws `FileUploadError(UPLOAD.STORAGE_FAILURE)` for missing keys so error-handling paths look identical between memory and fs adapters.
## Usage: Basic
```ts
import { MemoryUploadStorage } from "@suaveplan/api-plugin-file-upload/storage/memory";
const storage = new MemoryUploadStorage();
await storage.put("upload-001", new Uint8Array([0xff]));
const bytes = await storage.get("upload-001");
```
## Usage: Advanced
```ts
import { MemoryUploadStorage } from "@suaveplan/api-plugin-file-upload";
const storage = new MemoryUploadStorage();
await storage.put("a", new Uint8Array([1]));
await storage.put("b", new Uint8Array([2]));
const keys = storage.snapshot();
expect(keys).toContain("a");
await storage.delete("a");
expect(await storage.exists("a")).toBe(false);
```
## API Reference
- `class MemoryUploadStorage implements UploadStorage` — constructor takes no arguments.
- `put(key: string, bytes: Uint8Array): Promise<void>` — rejects empty `key`.
- `get(key: string): Promise<Uint8Array>` — throws on missing key.
- `delete(key: string): Promise<void>` — idempotent.
- `exists(key: string): Promise<boolean>`.
- `snapshot(): readonly string[]` — test-only key listing.
## Implementation Notes
- The defensive copy in `put` is `new Uint8Array(bytes)` so the stored buffer is independent of the caller's reference. Callers expecting structural sharing should layer a custom adapter.
- `delete` is intentionally idempotent (no error on missing key) to match the abort-route's contract: aborting twice is a no-op.
- The class is exported as the default test storage and is what `createFileUploadPlugin` falls back to when no `storage` option is provided.

View File

@@ -0,0 +1,74 @@
# `types.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the public TypeScript surface of `@suaveplan/api-plugin-file-upload`: the caller-facing `FileUploadPluginOptions`, the resolved `FileUploadPluginConfig`, the persisted `UploadRecord`, the in-flight `UploadSession`, and the two pluggable adapter contracts `UploadStorage` + `UploadSessionStore`. Every other file in the package imports from here; this is the single source of truth for the package's data model.
## Features
- Stable `UploadRecord` / `UploadSession` shapes consumable by sibling plugins (audit, search, billing) via the `fileUploadService` slot.
- `UploadStorage` interface separates wire-bytes persistence from session bookkeeping so production deployments can layer S3/local-fs/blob impls independently.
- `UploadSessionStore` carries the resume contract (`appendChunkIndex`, `listExpired`) so cleanup and crash-recovery are first-class operations.
- Options vs Config split mirrors the Zod input/output shape — callers pass partials, the schema applies defaults, descriptors see the resolved form.
## Usage: Basic
```ts
import type {
FileUploadPluginOptions,
UploadRecord,
} from "@suaveplan/api-plugin-file-upload/types/types";
const opts: FileUploadPluginOptions = { basePath: "/files" };
```
## Usage: Advanced
```ts
import type {
UploadSessionStore,
UploadSession,
UploadRecord,
} from "@suaveplan/api-plugin-file-upload/types/types";
class SurrealUploadSessionStore implements UploadSessionStore {
async create(s: UploadSession): Promise<void> {
/* INSERT INTO upload_session ... */
}
async read(id: string): Promise<UploadSession | undefined> {
return undefined;
}
async update(s: UploadSession): Promise<void> {}
async delete(id: string): Promise<void> {}
async appendChunkIndex(id: string, i: number): Promise<UploadSession> {
throw new Error("impl");
}
async listExpired(): Promise<readonly UploadSession[]> {
return [];
}
async readRecord(id: string): Promise<UploadRecord | undefined> {
return undefined;
}
async saveRecord(r: UploadRecord): Promise<void> {}
async deleteRecord(id: string): Promise<void> {}
}
```
## API Reference
- `FileUploadPluginOptions` — caller-supplied options; all fields optional.
- `FileUploadPluginConfig` — resolved shape with defaults applied; what the descriptor stores.
- `UploadRecord` — persisted result of a completed upload: `uploadId`, `principalId`, `tenantId?`, `fileName`, `size`, `sha256`, `mimeType`, `storageKey`, `createdAt`.
- `UploadSession` — in-flight chunked-upload state: `totalChunks`, `receivedChunks`, `chunkSize`, etc.
- `UploadStorage` — interface with `put`/`get`/`delete`/`exists` methods.
- `UploadSessionStore` — interface with `create`/`read`/`update`/`delete`/`appendChunkIndex`/`listExpired` + record-side helpers.
## Implementation Notes
- The Storage and Store contracts are intentionally separate: storage handles raw bytes, the store handles structured metadata. A single backing service can implement both, but most production deployments split them (e.g. S3 for bytes, Surreal/Redis for sessions).
- `receivedChunks` is a `ReadonlySet<number>` so iteration order is irrelevant and idempotent appends are O(1) on the impl side.
- All timestamps are `number` (ms since epoch) so they serialise cleanly through JSON without bespoke transformers.

View File

@@ -0,0 +1,56 @@
# `mime.ts`
## Purpose
Cross-checks the declared `Content-Type` against the plugin's configured allow/deny lists AND (optionally) against the magic-bytes signature of the upload's first 4 KiB. This is the spoof-detection layer: a client uploading an `.exe` with `Content-Type: text/plain` is rejected because the sniffed type disagrees with the declared one.
## Features
- Three rejection vectors: denylist, allow-list (when non-empty), and sniff-mismatch.
- Canonical lowercase `type/subtype` form returned so every downstream record stores a normalised value.
- Structured `context.reason` (`"deny-list"`, `"allow-list"`, `"sniff-mismatch"`, `"malformed"`) so handlers and dashboards can branch on rejection cause without parsing message text.
- Treats malformed declared `Content-Type` as a 415 rather than a 400 because the user payload was the data we couldn't accept — not the route shape.
- Sniff comparison only fires when magic bytes produce a confident match (it never blocks unknown formats).
## Usage: Basic
```ts
import { validateMime } from "@suaveplan/api-plugin-file-upload/validators/mime";
const canonical = validateMime({
declaredMimeType: "image/png",
allowedMimeTypes: ["image/png", "image/jpeg"],
deniedMimeTypes: [],
});
```
## Usage: Advanced
```ts
import { validateMime, FileUploadError } from "@suaveplan/api-plugin-file-upload";
const sample = new Uint8Array(buf.slice(0, 4096));
try {
validateMime({
declaredMimeType: "text/plain",
allowedMimeTypes: [],
deniedMimeTypes: [],
sniffSample: sample,
});
} catch (err) {
if (err instanceof FileUploadError) {
// err.context.reason === "sniff-mismatch"
}
}
```
## API Reference
- `ValidateMimeOptions` — `{ declaredMimeType, allowedMimeTypes, deniedMimeTypes, sniffSample? }`.
- `validateMime(opts: ValidateMimeOptions): string` — returns the canonical type; throws `FileUploadError(UPLOAD.MIME_REJECTED)` on any rejection vector.
## Implementation Notes
- `@suaveplan/mime`'s `detectFromMagicBytes` returns `undefined` for unrecognised payloads; we treat that as "no signal" and skip the cross-check, so unknown binary formats still upload successfully if the declared type is otherwise allowed.
- The "allow-list" semantics only engage when the array is non-empty so the default config (`allowedMimeTypes: []`) means "rely on the denylist alone".
- Comparisons are lowercase string-equality, not glob/regex, so callers needing `image/*` semantics should pre-expand the list at config time.

View File

@@ -0,0 +1,51 @@
# `size.ts`
## Purpose
Tiny but central pair of helpers that enforce upload size limits and compute chunked-upload geometry. `enforceSizeLimit` is the single throw site for `UPLOAD.TOO_LARGE`; `computeTotalChunks` is the canonical chunked-protocol math used by both the `initiate` handler and the service layer.
## Features
- Throws `FileUploadError(UPLOAD.INVALID_INPUT)` for malformed inputs (negative / non-finite byte counts, zero chunk size) so callers get the same error class for malformed-vs-too-large.
- `enforceSizeLimit` carries the actual + limit on `context` so the OTel span event records both.
- `computeTotalChunks` rounds up via `Math.ceil` so the last (potentially partial) chunk is always counted.
## Usage: Basic
```ts
import {
computeTotalChunks,
enforceSizeLimit,
} from "@suaveplan/api-plugin-file-upload/validators/size";
enforceSizeLimit(buffer.length, 10 * 1024 * 1024, "single-shot");
const totalChunks = computeTotalChunks(100 * 1024 * 1024, 5 * 1024 * 1024);
```
## Usage: Advanced
```ts
import {
enforceSizeLimit,
FileUploadError,
} from "@suaveplan/api-plugin-file-upload";
try {
enforceSizeLimit(buffer.length, config.singleShotLimitBytes, "POST /uploads");
} catch (err) {
if (err instanceof FileUploadError && err.uploadCode === "UPLOAD.TOO_LARGE") {
return c.json({ error: err.message }, 413);
}
throw err;
}
```
## API Reference
- `enforceSizeLimit(actualBytes: number, limit: number, label: string): void` — throws `UPLOAD.TOO_LARGE` (413) when over limit, `UPLOAD.INVALID_INPUT` (400) when input is malformed.
- `computeTotalChunks(totalSize: number, chunkSize: number): number` — returns `Math.ceil(totalSize / chunkSize)`.
## Implementation Notes
- The `label` argument is included verbatim in the error message so cross-cutting logs surface the call-site without a stack trace dive.
- `Math.ceil` returns `0` for `totalSize === 0` (which is excluded by the boundary schema's `.positive()` constraint) but we still tolerate that gracefully — `complete` would simply find no missing chunks and succeed with an empty-payload upload.