@orkestrel/scaffold 0.0.67 → 0.0.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/main.js +67 -44
- package/dist/bin/main.js.map +1 -1
- package/dist/host/agents/templates/brief.md +9 -0
- package/dist/host/claude/agents/orkestrel.md +4 -4
- package/dist/host/claude/rules/names.md +15 -0
- package/dist/host/claude/rules/tests.md +33 -4
- package/dist/host/claude/rules/workspace.md +14 -2
- package/dist/host/dotfiles/prettierignore +3 -0
- package/dist/host/guides/README.md +65 -0
- package/dist/host/guides/abort.md +169 -0
- package/dist/host/guides/agent.md +1509 -0
- package/dist/host/guides/brief.md +1266 -0
- package/dist/host/guides/browser.md +2200 -0
- package/dist/host/guides/budget.md +196 -0
- package/dist/host/guides/codec.md +519 -0
- package/dist/host/guides/console.md +785 -0
- package/dist/host/guides/contract.md +1193 -0
- package/dist/host/guides/csv.md +541 -0
- package/dist/host/guides/database.md +2518 -0
- package/dist/host/guides/emitter.md +233 -0
- package/dist/host/guides/form.md +1791 -0
- package/dist/host/guides/html.md +717 -0
- package/dist/host/guides/indexeddb.md +505 -0
- package/dist/host/guides/interpret.md +1029 -0
- package/dist/host/guides/lsp.md +515 -0
- package/dist/host/guides/markdown.md +964 -0
- package/dist/host/guides/mcp.md +5554 -0
- package/dist/host/guides/middleware.md +927 -0
- package/dist/host/guides/msg.md +440 -0
- package/dist/host/guides/ndjson.md +120 -0
- package/dist/host/guides/ollama.md +380 -0
- package/dist/host/guides/pool.md +280 -0
- package/dist/host/guides/probe.md +1210 -0
- package/dist/host/guides/process.md +1620 -0
- package/dist/host/guides/program.md +1110 -0
- package/dist/host/guides/qualifier.md +854 -0
- package/dist/host/guides/queue.md +370 -0
- package/dist/host/guides/rater.md +330 -0
- package/dist/host/guides/reason.md +1122 -0
- package/dist/host/guides/relation.md +373 -0
- package/dist/host/guides/router.md +753 -0
- package/dist/host/guides/scaffold.md +192 -31
- package/dist/host/guides/sea.md +383 -0
- package/dist/host/guides/server.md +752 -0
- package/dist/host/guides/sqlite.md +330 -0
- package/dist/host/guides/sse.md +187 -0
- package/dist/host/guides/supervisor.md +4890 -0
- package/dist/host/guides/table.md +1556 -0
- package/dist/host/guides/template.md +280 -0
- package/dist/host/guides/terminal.md +1145 -0
- package/dist/host/guides/test.md +2969 -0
- package/dist/host/guides/timeout.md +252 -0
- package/dist/host/guides/tool.md +311 -0
- package/dist/host/guides/toolbox.md +1038 -0
- package/dist/host/guides/websocket.md +282 -0
- package/dist/host/guides/worker.md +615 -0
- package/dist/host/guides/workflow.md +1507 -0
- package/dist/host/guides/workspace.md +595 -0
- package/dist/host/manifest.json +1218 -10
- package/dist/host/tests/policy.test.ts +279 -2
- package/dist/host/tests/setupPolicy.ts +437 -6
- package/dist/src/core/index.cjs +38 -16
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +33 -9
- package/dist/src/core/index.d.ts +33 -9
- package/dist/src/core/index.js +37 -17
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +1750 -1567
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +106 -24
- package/dist/src/server/index.d.ts +106 -24
- package/dist/src/server/index.js +1751 -1570
- package/dist/src/server/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
# Middleware
|
|
2
|
+
|
|
3
|
+
> Batteries for the `@orkestrel/server` middleware seam:
|
|
4
|
+
> `create{Noun}(options) => MiddlewareHandler<TState>` factories for error
|
|
5
|
+
> boundaries, telemetry, compression, security headers, CORS, deadlines,
|
|
6
|
+
> trusted-proxy client facts, ETag, bearer authentication, rate limiting, body
|
|
7
|
+
> parsing, sessions, and CSRF in the fetch-native core, plus in-memory assets,
|
|
8
|
+
> static files, streaming multipart uploads, and a `node:zlib` compression
|
|
9
|
+
> sibling in the node face.
|
|
10
|
+
|
|
11
|
+
This is the package's one guide, and it covers the core and the node face
|
|
12
|
+
together. Every battery composes the frozen `@orkestrel/server` middleware seam
|
|
13
|
+
(`MiddlewareHandler`, `MiddlewareContext`, `compose`) and its substrate —
|
|
14
|
+
cookies, WebCrypto tokens, negotiation, conditionals, and security primitives.
|
|
15
|
+
This package never re-implements that seam, and it supplies mechanism rather
|
|
16
|
+
than product policy. Source: [`src/core`](../src/core),
|
|
17
|
+
[`src/server`](../src/server). Surfaced through the `@orkestrel/middleware` and
|
|
18
|
+
`@orkestrel/middleware/server` barrels (aliased `@src/core` and `@src/server`
|
|
19
|
+
inside this repo).
|
|
20
|
+
|
|
21
|
+
## Surface
|
|
22
|
+
|
|
23
|
+
A battery closes over its guarded options and returns a
|
|
24
|
+
`MiddlewareHandler<TState>`, which composes with the others over the shipped
|
|
25
|
+
seam.
|
|
26
|
+
|
|
27
|
+
### Mount a battery
|
|
28
|
+
|
|
29
|
+
The fence composes an error boundary and the security battery over `compose`, then answers with the request identifier `createSecurity` stashed on `context.state`.
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { createBoundary, createSecurity } from '@orkestrel/middleware'
|
|
33
|
+
import type { IdentifierState } from '@orkestrel/middleware'
|
|
34
|
+
import { compose } from '@orkestrel/server'
|
|
35
|
+
|
|
36
|
+
interface State extends IdentifierState {}
|
|
37
|
+
|
|
38
|
+
const boundary = createBoundary({ expose: false })
|
|
39
|
+
const security = createSecurity({ hsts: true })
|
|
40
|
+
|
|
41
|
+
const handle = compose<State>([boundary, security], async (_request, context) => {
|
|
42
|
+
return Response.json({ identifier: context.state.identifier })
|
|
43
|
+
})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Middlewares — core
|
|
47
|
+
|
|
48
|
+
| API | Kind | Summary |
|
|
49
|
+
| ------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
50
|
+
| `createBoundary` | function | Creates the outermost error-rendering battery — catches a downstream throw and renders it as a `Response`. |
|
|
51
|
+
| `createTelemetry` | function | Creates the access-log/timing seam — records one `TelemetryEntry` per request after the response settles. |
|
|
52
|
+
| `createCompression` | function | Creates the response-body compression battery — negotiates and compresses a buffered response body over the runtime's feature-detected `CompressionStream` codings. |
|
|
53
|
+
| `createSecurity` | function | Creates the security-headers + request-identifier battery — sets each documented header default, and mints or echoes a request identifier. |
|
|
54
|
+
| `createCors` | function | Creates the Cross-Origin Resource Sharing battery — answers a preflight itself, and reflects an allow-listed origin or serves the configured wildcard. |
|
|
55
|
+
| `createDeadline` | function | Creates the application-level per-request deadline battery. |
|
|
56
|
+
| `createForwarded` | function | Creates the trusted-proxy client-IP resolver battery — walks `X-Forwarded-For` past the hops its options declare trusted. |
|
|
57
|
+
| `createETag` | function | Creates the dynamic response `ETag` + conditional GET battery (RFC 7232). |
|
|
58
|
+
| `createBearer` | function | Creates the bearer-token authentication battery — reads the token from its header and verifies it with `verifyToken`. |
|
|
59
|
+
| `createLimiter` | function | Creates the fixed-window rate-limiting battery — checks a key's budget before consuming it, so one window admits exactly `max` requests. |
|
|
60
|
+
| `createBody` | function | Creates the body-driving battery — eagerly awaits the cached `context.body()` so its throws (or a malformed-JSON `undefined`) surface before the handler runs, and stashes the resolved value onto `BodyState.body`. |
|
|
61
|
+
| `createSession` | function | Creates the generic session battery — resolves, mints, and persists a session across the request, with a mid-handler `regenerate`/`destroy` control handle. |
|
|
62
|
+
| `createCSRF` | function | Creates the session-bound double-submit CSRF protection battery. |
|
|
63
|
+
| `only` | function | Scopes a battery to a set of exact pathnames and nowhere else — outside that set it steps aside through `next()`. |
|
|
64
|
+
| `except` | function | Scopes a battery to every pathname outside a set of exact ones — on that set it steps aside through `next()`. |
|
|
65
|
+
|
|
66
|
+
### Middlewares — node
|
|
67
|
+
|
|
68
|
+
| API | Kind | Summary |
|
|
69
|
+
| ------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
70
|
+
| `createAssets` | function | Serves validated in-memory assets with identity/Brotli negotiation. |
|
|
71
|
+
| `createStatic` | function | Serves static files from `options.root` over `node:fs` — the node-bound static-file battery, answering conditional, ranged, and SPA-fallback requests. |
|
|
72
|
+
| `createMultipart` | function | Parses a streamed `multipart/form-data` request body and stashes its `MultipartBody` on `context.state.multipart` — the node-bound streaming multipart battery. |
|
|
73
|
+
| `createCompression` | function | Compresses response bodies through `node:zlib`, guaranteed on any Node runtime rather than dependent on the WHATWG `CompressionStream` global. This battery is the node-bound sibling of the core face's feature-detected `createCompression`, and it ships from a separate package entry point (`@orkestrel/middleware/server`) so the shared name is unambiguous per consumer import path. |
|
|
74
|
+
|
|
75
|
+
### Types
|
|
76
|
+
|
|
77
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. An extended interface's name comes before `plus`, with the members it adds after.
|
|
78
|
+
|
|
79
|
+
| Type | Kind | Shape | Summary |
|
|
80
|
+
| --------------------------- | --------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
81
|
+
| `BoundaryOptions` | interface | `{ expose?, report? }` | Configures `createBoundary` — the outermost error-rendering battery. |
|
|
82
|
+
| `TelemetryEntry` | interface | `{ method, pathname, status, duration }` | Represents one access-log-style entry `createTelemetry` records after a response settles — the access-log/timing seam's payload shape. |
|
|
83
|
+
| `TelemetryOptions` | interface | `{ record }` | Configures `createTelemetry` — the request timing/access-log seam. |
|
|
84
|
+
| `CompressionOptions` | interface | `{ threshold?, encodings?, filter? }` | Configures `createCompression` — response-body compression. |
|
|
85
|
+
| `CompressResponseOptions` | interface | `{ threshold, filter?, encodings, compress }` | Describes the already-resolved settings `compressResponse` runs its shared negotiate → skip → threshold → compress skeleton against — the shape each face's `createCompression` builds from its own option bag. |
|
|
86
|
+
| `SecurityIdentifierOptions` | type | `{ trust? } \| false` | Describes `createSecurity`'s `identifier` sub-option — request-id minting/echo policy, or `false` to disable the feature entirely. |
|
|
87
|
+
| `SecurityOptions` | interface | `{ frame?, csp?, referrer?, permissions?, coop?, corp?, cluster?, coep?, hsts?, identifier? }` | Configures `createSecurity` — the security-headers + request-id battery. |
|
|
88
|
+
| `CorsOptions` | interface | `{ origin?, methods?, headers? }` | Configures `createCors` — Cross-Origin Resource Sharing. |
|
|
89
|
+
| `DeadlineOptions` | interface | `{ ms, status? }` | Configures `createDeadline` — the application-level per-request deadline. |
|
|
90
|
+
| `ForwardedOptions` | type | `{ proxies } \| { trusted }` | Configures `createForwarded` — the trusted-proxy client-IP resolver. |
|
|
91
|
+
| `ETagOptions` | interface | `{ weak? }` | Configures `createETag` — dynamic response ETag + conditional GET. |
|
|
92
|
+
| `BearerOptions` | interface | `{ secret, header?, scheme? }` | Configures `createBearer` — bearer-token authentication. |
|
|
93
|
+
| `LimiterOptions` | interface | `{ max, window, capacity?, key?, message?, clock?, policy?, evict? }` | Configures `createLimiter` — fixed-window rate limiting. |
|
|
94
|
+
| `BearerState` | interface | `{ token? }` | Describes the bearer-authentication state slice `createBearer` stashes on `context.state` once a token verifies. |
|
|
95
|
+
| `IdentifierState` | interface | `{ identifier? }` | Describes the request-identifier state slice `createSecurity` stashes when its `identifier` option is enabled. |
|
|
96
|
+
| `Client` | interface | `{ ip? }` | Describes the resolved client connection facts `createForwarded` stashes. |
|
|
97
|
+
| `ClientState` | interface | `{ client? }` | Describes the client-facts state slice `createForwarded` stashes. |
|
|
98
|
+
| `ConnectionState` | interface | `{ connection? }` | Describes the connection-facts state slice `createLimiter`'s default key derivation falls back to when neither `BearerState` nor `ClientState` is present — the raw socket peer surfaced on `context.state` by the server's `state` option. |
|
|
99
|
+
| `SessionInterface` | interface | `{ id, state } plus set, delete, clear` | Represents a server-managed session's public surface — an id, its live state, and the mutators that write it. |
|
|
100
|
+
| `SessionControlInterface` | interface | `{} plus regenerate, destroy` | Describes the mid-handler control handle `createSession` stashes alongside the session itself — the OWASP anti-fixation / logout primitives. |
|
|
101
|
+
| `SessionState` | interface | `{ session?, control? }` | Describes the session state slice `createSession` stashes. |
|
|
102
|
+
| `BodyState` | interface | `{ body? }` | Describes the body state slice `createBody` stashes. |
|
|
103
|
+
| `SessionStoreInterface` | interface | `{} plus get, set, delete` | Describes the pluggable session persistence seam `createSession`'s `store` option implements — a point-access store keyed by session id. |
|
|
104
|
+
| `SessionTransportInterface` | interface | `{} plus read, write, clear` | Describes the transport seam `createSession`'s `transport` option implements — how a session id travels to and from the client (a signed cookie, a header, …). |
|
|
105
|
+
| `SessionOptions` | interface | `{ transport, store?, ttl?, lifetime?, capacity?, evict?, create?, mint?, required?, clock? }` | Configures `createSession` — the generic session battery. |
|
|
106
|
+
| `CookieTransportOptions` | interface | `{ name?, secret, cookie? }` | Configures `createCookieTransport` — the signed-cookie `SessionTransportInterface`. |
|
|
107
|
+
| `HeaderTransportOptions` | interface | `{ header? }` | Configures `createHeaderTransport` — the bare-header `SessionTransportInterface`. |
|
|
108
|
+
| `MemorySessionStoreOptions` | interface | `SessionLimits plus { capacity?, evict? }` | Configures `createMemorySessionStore` — the default in-process `SessionStoreInterface`. |
|
|
109
|
+
| `SessionLimits` | interface | `{ ttl?, lifetime? }` | Describes the idle and absolute-lifetime thresholds a session store enforces — `sessionExpired`'s limits argument and both shipped stores' construction options. |
|
|
110
|
+
| `SessionCursors` | interface | `{ seen, created }` | Describes the per-session instants a store stamps and `sessionExpired` measures against. |
|
|
111
|
+
| `SessionRow` | interface | `SessionCursors plus { id, session }` | Represents one persisted session row — an opaque snapshot column plus the store-owned idle/absolute-lifetime cursors, the shape a `DatabaseSessionStore`'s backing table holds. |
|
|
112
|
+
| `SessionEntry` | interface | `SessionCursors plus { session }` | Represents one in-process session entry — the payload `MemorySessionStore` holds against an id, alongside the same cursors a persisted row carries. |
|
|
113
|
+
| `SessionSnapshot` | interface | `{ id, state }` | Represents a session's serializable projection — the value `snapshotSession` produces and a durable store's `set` writes. |
|
|
114
|
+
| `SessionRestoreFunction` | type | `(value: unknown) => SessionInterface \| undefined` | Rebuilds a session entity from an untrusted stored snapshot, or resolves `undefined` when the value is malformed. |
|
|
115
|
+
| `CSRFState` | interface | `{ csrf? }` | Describes the CSRF state slice `createCSRF` stashes — the raw token a safe-method response exposes for a subsequent mutating request to submit back. |
|
|
116
|
+
| `CSRFOptions` | interface | `{ secret, cookie?, header?, field?, safe? }` | Configures `createCSRF` — session-bound double-submit CSRF protection. |
|
|
117
|
+
| `MultipartFile` | interface | `{ field, name, size, mime, validated, status, path }` | Represents one staged multipart upload's public record — the shape the node-face `createMultipart` battery (`@orkestrel/middleware/server`) produces per uploaded file. |
|
|
118
|
+
| `MultipartBody` | interface | `{ files, fields }` | Describes the parsed multipart request body `createMultipart` stashes — files keyed by their field name, plus every plain text field. |
|
|
119
|
+
| `MultipartState` | interface | `{ multipart? }` | Describes the multipart state slice `createMultipart` stashes. |
|
|
120
|
+
| `Asset` | interface | `{ body, encoding? }` | Describes one in-memory asset representation returned by an `AssetSourceInterface`. |
|
|
121
|
+
| `AssetSourceInterface` | interface | `{} plus read` | Reads in-memory assets by decoded, browser-build-relative path. |
|
|
122
|
+
| `AssetOptions` | interface | `{ source }` | Configures `createAssets` — in-memory identity/Brotli asset serving. |
|
|
123
|
+
| `StaticOptions` | interface | `{ root, prefix?, index?, dotfiles?, cache?, etag?, fallback? }` | Configures `createStatic` — node `fs`-backed static file serving. |
|
|
124
|
+
| `MultipartLimitsInput` | interface | `{ file?, field?, total? }` | Describes the caller's partial `MultipartLimits` — `createMultipart`'s `limits` option, with every member optional. |
|
|
125
|
+
| `MultipartLimits` | interface | `{ file, field, total }` | Describes the per-category size/count caps `createMultipart` enforces mid-stream — the effective limits, every documented default already applied. |
|
|
126
|
+
| `MultipartOptions` | interface | `{ limits?, allowed?, directory? }` | Configures `createMultipart` — node `fs`/`os`/`crypto`-backed streaming multipart upload parsing. |
|
|
127
|
+
| `NodeCompressionOptions` | interface | `{ threshold?, filter? }` | Configures the node face's `createCompression` — `node:zlib`-backed response compression. |
|
|
128
|
+
| `MultipartErrorCode` | type | `'limit' \| 'malformed' \| 'rejected'` | Names the reason `createMultipart` rejected a request — the machine-readable code `MultipartError` carries and maps onto its HTTP status: `'limit'` → 413, `'malformed'` → 400, `'rejected'` → 415. |
|
|
129
|
+
| `UploadStatus` | type | `'staged' \| 'moved'` | Names the lifecycle stage of one staged upload's temp file. |
|
|
130
|
+
| `UploadedFile` | interface | `Omit<MultipartFile, 'status'> plus { status }` | Describes one uploaded file's post-parse record — the node-bound, richer sibling of the pure core's `MultipartFile` (identical fields, `status` narrowed to `UploadStatus`). Structurally assignable into `MultipartFile` so a `createMultipart`-built `MultipartBody` satisfies the shared core shape. |
|
|
131
|
+
| `PartHeaders` | interface | `{ name, filename, mime }` | Describes one multipart part's parsed header block — `parsePartHeaders`'s return shape. |
|
|
132
|
+
| `ByteRange` | interface | `{ start, end }` | Describes one inclusive byte range over a file — `streamFile`'s optional `range` argument and the shape `createStatic` builds for a satisfiable `Range` request. |
|
|
133
|
+
|
|
134
|
+
### Constants
|
|
135
|
+
|
|
136
|
+
A `Shape` cell holds the constant's declared type.
|
|
137
|
+
|
|
138
|
+
| API | Kind | Shape | Summary |
|
|
139
|
+
| --------------------------------- | ----- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
140
|
+
| `DEFAULT_COMPRESSION_THRESHOLD` | const | `number` | Holds `1024`, the default minimum buffered body size in bytes `createCompression` will compress. |
|
|
141
|
+
| `DEFAULT_COMPRESSION_ENCODINGS` | const | `readonly Encoding[]` | Lists `['gzip', 'deflate']`, the default content-codings `createCompression` offers in preference order — intersected at construction with what the runtime's `CompressionStream` actually supports. |
|
|
142
|
+
| `DEFAULT_FRAME_OPTIONS` | const | `string` | Holds `'DENY'`, the default `X-Frame-Options` value `createSecurity` sets. |
|
|
143
|
+
| `DEFAULT_CSP` | const | `string` | Holds `"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'"`, the default `Content-Security-Policy` value `createSecurity` sets — a custom `csp` option replaces this wholesale, never merges. |
|
|
144
|
+
| `DEFAULT_REFERRER_POLICY` | const | `string` | Holds `'strict-origin-when-cross-origin'`, the default `Referrer-Policy` value `createSecurity` sets. |
|
|
145
|
+
| `DEFAULT_PERMISSIONS_POLICY` | const | `string` | Holds `'camera=(), microphone=(), geolocation=()'`, the default `Permissions-Policy` value `createSecurity` sets. |
|
|
146
|
+
| `DEFAULT_COOP` | const | `string` | Holds `'same-origin'`, the default `Cross-Origin-Opener-Policy` value `createSecurity` sets. |
|
|
147
|
+
| `DEFAULT_CORP` | const | `string` | Holds `'same-origin'`, the default `Cross-Origin-Resource-Policy` value `createSecurity` sets. |
|
|
148
|
+
| `DEFAULT_CLUSTER` | const | `string` | Holds `'?1'`, the default `Origin-Agent-Cluster` value `createSecurity` sets. |
|
|
149
|
+
| `DEFAULT_COEP` | const | `string` | Holds `'require-corp'`, the value `createSecurity` sets for `Cross-Origin-Embedder-Policy` when `coep: true`. |
|
|
150
|
+
| `DEFAULT_HSTS` | const | `string` | Holds `'max-age=31536000; includeSubDomains'`, the value `createSecurity` sets for `Strict-Transport-Security` when `hsts: true`. |
|
|
151
|
+
| `DEFAULT_IDENTIFIER_HEADER` | const | `string` | Names `'x-request-id'`, the default header `createSecurity` mints or echoes a request identifier into. |
|
|
152
|
+
| `DEFAULT_CORS_METHODS` | const | `readonly string[]` | Lists the default methods `createCors` advertises on a preflight response. |
|
|
153
|
+
| `DEFAULT_CORS_HEADERS` | const | `readonly string[]` | Lists the default headers `createCors` advertises on a preflight response. |
|
|
154
|
+
| `DEFAULT_DEADLINE_STATUS` | const | `number` | Holds `503`, the default response status `createDeadline` returns when its deadline fires first. |
|
|
155
|
+
| `DEFAULT_BEARER_HEADER` | const | `string` | Names `'authorization'`, the default header `createBearer` reads the token from. |
|
|
156
|
+
| `DEFAULT_BEARER_SCHEME` | const | `string` | Names `'Bearer'`, the default scheme prefix `createBearer` strips before verification. |
|
|
157
|
+
| `DEFAULT_LIMITER_CAPACITY` | const | `number` | Holds `10_000`, the default maximum number of distinct rate-limit keys `createLimiter` tracks before LRU eviction. |
|
|
158
|
+
| `DEFAULT_LIMITER_MESSAGE` | const | `string` | Holds `'rate limit exceeded'`, the default 429 body message `createLimiter` sends when a key is over budget. |
|
|
159
|
+
| `DEFAULT_SESSION_CAPACITY` | const | `number` | Holds `10_000`, the default maximum number of distinct session ids `createMemorySessionStore` tracks before LRU (by last write) eviction. |
|
|
160
|
+
| `DEFAULT_SESSION_COOKIE` | const | `string` | Names `'session'`, the default cookie `createCookieTransport` writes the signed session id under. |
|
|
161
|
+
| `DEFAULT_SESSION_HEADER` | const | `string` | Names `'session-id'`, the default header `createHeaderTransport` carries the session id in. |
|
|
162
|
+
| `DEFAULT_CSRF_COOKIE` | const | `string` | Names `'csrf'`, the default signed cookie `createCSRF` writes the CSRF token under. |
|
|
163
|
+
| `DEFAULT_CSRF_HEADER` | const | `string` | Names `'x-csrf-token'`, the default header `createCSRF` reads a mutating request's submitted token from. |
|
|
164
|
+
| `DEFAULT_CSRF_FIELD` | const | `string` | Names `'_csrf'`, the default body field `createCSRF` falls back to reading a mutating request's submitted token from. |
|
|
165
|
+
| `DEFAULT_CSRF_SAFE_METHODS` | const | `readonly string[]` | Lists `['GET', 'HEAD', 'OPTIONS']`, the default methods `createCSRF` treats as safe (mint instead of verify). |
|
|
166
|
+
| `MULTIPART_STATUS` | const | `Readonly<Record<MultipartErrorCode, number>>` | Holds the HTTP status `createMultipart` renders for each `MultipartErrorCode`: `'limit'` is 413, `'malformed'` is 400, and `'rejected'` is 415. |
|
|
167
|
+
| `MULTIPART_ERROR_BRAND` | const | `unique symbol` | Holds the `Symbol.for` brand `MultipartError` carries so `isMultipartError` recognizes an instance across duplicate copies of this package — a registry symbol rather than a module-local `Symbol()`, which would mint an unequal symbol per copy. |
|
|
168
|
+
| `NODE_COMPRESSION_ENCODINGS` | const | `readonly Encoding[]` | Lists `['gzip', 'deflate']`, the content-codings the node face's `createCompression` offers — what `node:zlib` guarantees on every Node runtime, so this face never feature-detects. |
|
|
169
|
+
| `DEFAULT_STATIC_INDEX` | const | `string` | Names `'index.html'`, `createStatic`'s default directory-index filename. |
|
|
170
|
+
| `DEFAULT_STATIC_FALLBACK_EXCLUDE` | const | `string` | Names `'/api'`, `createStatic`'s `fallback: true` default excluded path prefix. |
|
|
171
|
+
| `DEFAULT_STATIC_DOTFILES` | const | `NonNullable<StaticOptions['dotfiles']>` | Names `'ignore'`, `createStatic`'s default policy for a path carrying a dotfile segment. |
|
|
172
|
+
| `DEFAULT_CONTENT_TYPE` | const | `string` | Names `'application/octet-stream'`, the MIME type served when a file extension has no known mapping. |
|
|
173
|
+
| `DEFAULT_MULTIPART_FILE_SIZE` | const | `number` | Holds `10_485_760`, `createMultipart`'s default per-file byte-size cap. |
|
|
174
|
+
| `DEFAULT_MULTIPART_FILE_COUNT` | const | `number` | Holds `10`, `createMultipart`'s default maximum file-part count. |
|
|
175
|
+
| `DEFAULT_MULTIPART_FIELD_SIZE` | const | `number` | Holds `65_536`, `createMultipart`'s default per-field byte-size cap. |
|
|
176
|
+
| `DEFAULT_MULTIPART_FIELD_COUNT` | const | `number` | Holds `100`, `createMultipart`'s default maximum field-part count. |
|
|
177
|
+
| `DEFAULT_MULTIPART_TOTAL` | const | `number` | Holds `52_428_800`, `createMultipart`'s default combined request-body byte-size cap. |
|
|
178
|
+
| `MULTIPART_MAX_HEADER_BLOCK` | const | `number` | Holds `16_384`, the maximum bytes a single multipart part's header block may occupy before it is malformed. |
|
|
179
|
+
| `MULTIPART_MAX_PREAMBLE` | const | `number` | Holds `65_536`, the maximum bytes scanned before the first multipart boundary is found before it is malformed. |
|
|
180
|
+
| `RESERVED_DEVICE_NAMES` | const | `ReadonlySet<string>` | Lists the Windows reserved device-name stems (CVE-2025-27210) — matched case-insensitively against the segment's stem (before its first `.`). |
|
|
181
|
+
| `EXTENSION_TYPES` | const | `Readonly<Record<string, string>>` | Holds the file-extension (lowercase, with leading `.`) → MIME type lookup table for static serving. |
|
|
182
|
+
|
|
183
|
+
### Shapers
|
|
184
|
+
|
|
185
|
+
A `Shape` cell holds the constant's declared type.
|
|
186
|
+
|
|
187
|
+
| API | Kind | Shape | Summary |
|
|
188
|
+
| ---------------- | ----- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
189
|
+
| `sessionColumns` | const | `{ id, session, seen, created }` | Holds the `@orkestrel/database` column shape for a `SessionRow` table. Pass it as-is to `createDatabase({ tables: { sessions: sessionColumns } })` so an app declaring a durable session table never hand-writes the shape. |
|
|
190
|
+
|
|
191
|
+
### Helpers — core
|
|
192
|
+
|
|
193
|
+
| API | Kind | Summary |
|
|
194
|
+
| --------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
195
|
+
| `resolveKey` | function | Derives `createLimiter`'s default rate-limit bucket key from a request's resolved identity facts. |
|
|
196
|
+
| `resolveOptInHeader` | function | Resolves an opt-in, value-bearing security header — `string \| boolean` (off by default, `true` uses the secure default), the shape `createSecurity`'s `coep`/`hsts` options use, distinct from the plain value-or-`false` shape `resolveSecurityHeader` (the peer substrate) handles. |
|
|
197
|
+
| `buildRetryAfter` | function | Builds the `Retry-After` header value — whole seconds until a window reset, floored at a minimum of `1`. |
|
|
198
|
+
| `buildRateLimitField` | function | Builds the draft `RateLimit` structured header field — emitted only when `createLimiter`'s `policy` option is `true`. |
|
|
199
|
+
| `buildRateLimitPolicyField` | function | Builds the draft `RateLimit-Policy` structured header field — emitted only when `createLimiter`'s `policy` option is `true`. |
|
|
200
|
+
| `matchesTrustedEntry` | function | Checks whether a candidate address is a bare (non-CIDR) trusted-hop match — an exact string match, or a simple prefix-CIDR match for IPv4 (`/8`–`/32`). An IPv6 entry matches by exact string only — there is no IPv6 CIDR support. |
|
|
201
|
+
| `resolveForwardedFor` | function | Walks `X-Forwarded-For` right-to-left and resolves the first untrusted hop address — `createForwarded`'s core algorithm. |
|
|
202
|
+
| `detectEncodings` | function | Feature-detects which of `candidates` the runtime's `CompressionStream` actually supports — `createCompression`'s construction-time intersection. |
|
|
203
|
+
| `compressBytes` | function | Compresses bytes with the host-independent `CompressionStream` primitive. |
|
|
204
|
+
| `isBufferingIneligible` | function | Checks whether a response must skip the compression and ETag buffering pipeline — the shared cheap-skip predicate both batteries apply before ever touching `response.arrayBuffer()`, true for a `HEAD` request, a `204`/`304` or otherwise bodyless response, an `event-stream` response, and a response already carrying the header the caller is about to set. |
|
|
205
|
+
| `isCompressionNegotiated` | function | Checks whether a negotiated `Accept-Encoding` outcome is worth acting on — `createCompression`'s negotiation-eligibility half of the skip list. |
|
|
206
|
+
| `rebuildResponse` | function | Rebuilds a `Response` around a replacement body while preserving its status/statusText — the buffered-response reconstruction shared by the compression and ETag batteries after they have consumed `response.arrayBuffer()`. |
|
|
207
|
+
| `compressResponse` | function | Runs the shared negotiate → skip → threshold → compress → header-set skeleton both faces' `createCompression` batteries compose — response-body compression over a caller-supplied set of feature-detected codings. |
|
|
208
|
+
| `transferSessionState` | function | Copies every entry of one session's `state` into another — the regenerate state-carry `createSession`'s `control.regenerate()` applies. |
|
|
209
|
+
| `sessionExpired` | function | Checks whether a session has aged past its idle timeout or absolute lifetime as of `now` — the pure expiry predicate `MemorySessionStore` delegates to. |
|
|
210
|
+
| `snapshotSession` | function | Snapshots a session's `state` into a plain, serializable record — the projection a durable store's `set` writes to disk. |
|
|
211
|
+
| `validateSessionLimits` | function | Validates a store's idle and absolute-lifetime thresholds, throwing when either is present and malformed — the shared construction gate `MemorySessionStore` and `DatabaseSessionStore` both apply, so one malformed `ttl` is refused identically by whichever store receives it. |
|
|
212
|
+
| `isPreflight` | function | Determines whether a request is a CORS preflight — an `OPTIONS` request carrying an `Access-Control-Request-Method` header. |
|
|
213
|
+
| `buildClient` | function | Builds the `Client` slice `createForwarded` stashes, from the resolved client IP — a leaf shaping helper. |
|
|
214
|
+
| `equalsConstantTime` | function | Compares two strings in constant time — `createCSRF`'s double-submit token comparison, avoiding a timing oracle on the submitted-vs-cookie match. |
|
|
215
|
+
|
|
216
|
+
### Validators — core
|
|
217
|
+
|
|
218
|
+
Each guard in the following table is total: it accepts any input, returns `false` off-shape, and never throws.
|
|
219
|
+
|
|
220
|
+
In a guard table a `Shape` cell holds the type the guard narrows to.
|
|
221
|
+
|
|
222
|
+
| API | Kind | Shape | Summary |
|
|
223
|
+
| ------------------ | -------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
224
|
+
| `isSession` | function | `SessionInterface` | Determines whether a value implements `SessionInterface` — a total structural guard: an `id` string, a `state` `Map`, and the `set`, `delete`, and `clear` mutators. Prototype-agnostic — accepts a plain object, a null-prototype object, and a class instance (a real `Session`), because a restored or stored session is routinely a class instance rather than a literal. |
|
|
225
|
+
| `isSessionControl` | function | `SessionControlInterface` | Determines whether a value implements `SessionControlInterface` — a total structural guard: callable `regenerate` and `destroy`. |
|
|
226
|
+
| `isMultipartFile` | function | `MultipartFile` | Determines whether a value is one staged `MultipartFile` record — a total structural guard checking every required field's shape. |
|
|
227
|
+
| `isMultipartBody` | function | `MultipartBody` | Determines whether a value implements `MultipartBody` — a total structural guard: `files` keyed by field name to arrays of `MultipartFile`, and a `fields` string record. |
|
|
228
|
+
|
|
229
|
+
### Helpers — node
|
|
230
|
+
|
|
231
|
+
| API | Kind | Summary |
|
|
232
|
+
| --------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
233
|
+
| `resolveStaticPath` | function | Resolves a request pathname to an on-disk path under `root`, or `undefined` when it cannot — the traversal guard, whose algorithm and order are exact: strip `prefix` on a segment boundary → `decodeURIComponent` (a malformed escape refuses, never throws) → reject a NUL byte → strip the leading path separator first (so a leading `..` survives `normalize` as a genuine climbing segment) → `normalize` → refuse any Windows reserved-device-name segment (`isReservedDeviceName`) → `resolve` and require the result under `root`. |
|
|
234
|
+
| `isUnderPath` | function | Checks whether `pathname` is `prefix` itself or lies under it on a segment boundary — the shared under-path test `resolveStaticPath`'s prefix strip and `createStatic`'s SPA-fallback `exclude` both apply, so `exclude: '/api'` matches `/api` and `/api/x` but never `/apifoo`. |
|
|
235
|
+
| `resolveStaticFallbackPath` | function | Resolves the fixed SPA shell path when a static-file miss is eligible for fallback. |
|
|
236
|
+
| `isContainedPath` | function | Checks whether `child` is `parent` itself or lies inside it on-disk — the filesystem containment predicate `createStatic` applies to `fs.realpath` output (never to a URL pathname — that is `isUnderPath`'s job). |
|
|
237
|
+
| `resolveContainedRealPath` | function | Canonicalizes `candidate` and returns it only when it lies inside `rootReal` — the shared realpath-then-contain step `createStatic` applies to a directory index and to its SPA shell. |
|
|
238
|
+
| `isReservedDeviceName` | function | Checks whether a path segment is a Windows reserved device name (CVE-2025-27210). |
|
|
239
|
+
| `isDotfilePath` | function | Checks whether a relative path (already resolved under a static root) has any segment starting with `.` — a dotfile or dot-directory. |
|
|
240
|
+
| `lookupContentType` | function | Looks up the MIME type for a static file path by its extension. |
|
|
241
|
+
| `computeFileETag` | function | Computes a static file's weak ETag from its size and modification time. |
|
|
242
|
+
| `detectMIME` | function | Sniffs a MIME type from a file's leading bytes against a small magic-byte table (jpeg, png, gif87a/89a, webp, pdf, zip). |
|
|
243
|
+
| `matchesBytes` | function | Checks whether `bytes` contains `signature` at the requested offset. |
|
|
244
|
+
| `compressNodeBytes` | function | Compresses response bytes with Node's guaranteed zlib gzip/deflate codecs. |
|
|
245
|
+
| `extractMultipartBoundary` | function | Extracts the `boundary` parameter from a `Content-Type` header, or `undefined` when the request is not `multipart/form-data`. |
|
|
246
|
+
| `parsePartHeaders` | function | Parses one multipart part's raw header block into its `name` (from `Content-Disposition`), optional `filename`, and optional `Content-Type`. |
|
|
247
|
+
| `resolveMultipartLimits` | function | Resolves `createMultipart`'s effective `MultipartLimits`, applying every documented default to an omitted leaf. |
|
|
248
|
+
| `createUploadedFile` | function | Builds a frozen `UploadedFile` record. |
|
|
249
|
+
| `streamFile` | function | Adapts a `node:fs` read stream over a file path (or an already-open `FileHandle`) into a DOM-compatible `ReadableStream<Uint8Array>` — the single shared node↔web stream bridge every static-file and uploaded-file response body routes through. |
|
|
250
|
+
| `streamUploadedFile` | function | Opens a staged/moved uploaded file as a web `ReadableStream`. |
|
|
251
|
+
| `readUploadedFile` | function | Reads a staged/moved uploaded file's full contents into memory. |
|
|
252
|
+
| `moveUploadedFile` | function | Moves a staged uploaded file to its final `destination`. |
|
|
253
|
+
| `unlinkStagedFiles` | function | Attempts to unlink every still-`'staged'` file in a parsed `MultipartBody` — the fail-closed cleanup `createMultipart` runs when its downstream handler throws, mirroring `parseMultipartRequest`'s own cleanup pattern (a missing file is already gone; failures are swallowed). |
|
|
254
|
+
|
|
255
|
+
### Parsers — node
|
|
256
|
+
|
|
257
|
+
| API | Kind | Summary |
|
|
258
|
+
| ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
259
|
+
| `parseMultipartRequest` | function | Stream-parses a `multipart/form-data` request into its files and fields — the mid-stream state machine `createMultipart` drives. |
|
|
260
|
+
|
|
261
|
+
### Classes
|
|
262
|
+
|
|
263
|
+
| API | Kind | Summary |
|
|
264
|
+
| ---------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
265
|
+
| `Session` | class | Represents a server-managed session's default entity — the `create` option's default value for `createSession`. It ships without a bare `create*` factory of its own, because the name `createSession` belongs to the battery; `createRestoredSession` rebuilds one from a stored snapshot. |
|
|
266
|
+
| `MemorySessionStore` | class | Implements the default in-process `SessionStoreInterface` — a `Map`-backed store enforcing both an idle timeout and an absolute lifetime, with lazy (read-time) eviction, a bounded capacity, and no background timers. |
|
|
267
|
+
| `DatabaseSessionStore` | class | Implements a durable `SessionStoreInterface` over an `@orkestrel/database` table — the same idle-timeout + absolute-lifetime contract as `MemorySessionStore`, backed by a caller-supplied `TableInterface` instead of an in-process `Map`. |
|
|
268
|
+
|
|
269
|
+
Multipart parsing has no entity row because it exposes no entity. The server source declares a
|
|
270
|
+
multipart lifecycle engine that `parseMultipartRequest` composes internally, and the barrel does not
|
|
271
|
+
export it: a consumer reaches every part of that behaviour through `parseMultipartRequest`, whose
|
|
272
|
+
result is a `MultipartBody`. `tests/guides.test.ts` names the class in its `INTERNAL` list, so the
|
|
273
|
+
omission is asserted rather than assumed, and adding it to the barrel would turn that assertion red.
|
|
274
|
+
|
|
275
|
+
### Factories
|
|
276
|
+
|
|
277
|
+
| API | Kind | Summary |
|
|
278
|
+
| ---------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
279
|
+
| `createCookieTransport` | function | Creates a signed-cookie `SessionTransportInterface` — the session id travels as a `signToken`-signed cookie value. |
|
|
280
|
+
| `createHeaderTransport` | function | Creates a bare-header `SessionTransportInterface` — the session id travels verbatim in a request/response header. |
|
|
281
|
+
| `createMemorySessionStore` | function | Creates the default in-process `SessionStoreInterface` — a `Map`-backed store enforcing an idle timeout and an absolute lifetime. |
|
|
282
|
+
| `createDatabaseSessionStore` | function | Creates a `DatabaseSessionStore` as a `SessionStoreInterface` — the durable counterpart to `createMemorySessionStore`, over a caller-opened `@orkestrel/database` table (declare it with `sessionColumns`). |
|
|
283
|
+
| `createRestoredSession` | function | Rebuilds a `Session` from an untrusted snapshot value — the inverse of `snapshotSession` and a durable store's `get` deserialization step. |
|
|
284
|
+
|
|
285
|
+
### Errors
|
|
286
|
+
|
|
287
|
+
| API | Kind | Summary |
|
|
288
|
+
| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
289
|
+
| `MultipartError` | class | Represents an error `createMultipart` throws when a streamed multipart request fails a mid-stream limit, is structurally malformed, or has a file whose sniffed bytes are rejected by the configured `allowed` MIME list. |
|
|
290
|
+
| `isMultipartError` | function | Narrows an unknown caught value to a `MultipartError`. |
|
|
291
|
+
|
|
292
|
+
## Methods
|
|
293
|
+
|
|
294
|
+
The public methods of `AssetSourceInterface`, `SessionInterface`,
|
|
295
|
+
`SessionControlInterface`, `SessionStoreInterface`, and
|
|
296
|
+
`SessionTransportInterface` — the behavioral seams the middleware factories
|
|
297
|
+
compose (their `readonly` data members, where any exist, stay Surface rows
|
|
298
|
+
in the preceding section).
|
|
299
|
+
|
|
300
|
+
#### `AssetSourceInterface`
|
|
301
|
+
|
|
302
|
+
The in-memory lookup seam. `createAssets` validates a request key before it
|
|
303
|
+
calls `read`, copies each successful result, and caches that key for the
|
|
304
|
+
factory's lifetime. A miss may be read again later.
|
|
305
|
+
|
|
306
|
+
| Method | Returns | Summary |
|
|
307
|
+
| ------ | -------------------- | ------------------------------------------------------------------------------- |
|
|
308
|
+
| `read` | `Asset \| undefined` | Reads one identity or Brotli asset representation for a validated relative key. |
|
|
309
|
+
|
|
310
|
+
#### `SessionInterface`
|
|
311
|
+
|
|
312
|
+
The session entity's own write seam. `state` is a `ReadonlyMap` view:
|
|
313
|
+
TypeScript refuses a write through it, and `set`, `delete`, and `clear` are
|
|
314
|
+
the write path.
|
|
315
|
+
|
|
316
|
+
| Method | Returns | Summary |
|
|
317
|
+
| -------- | --------- | -------------------------------------------------------- |
|
|
318
|
+
| `set` | `void` | Writes one key's value into the session's state. |
|
|
319
|
+
| `delete` | `boolean` | Removes one key from the session's state. |
|
|
320
|
+
| `clear` | `void` | Empties the state, leaving the session and its id alive. |
|
|
321
|
+
|
|
322
|
+
#### `SessionControlInterface`
|
|
323
|
+
|
|
324
|
+
`regenerate` is the OWASP anti-fixation primitive (rotate the id, keep the
|
|
325
|
+
state); `destroy` ends the session outright. Both record intent
|
|
326
|
+
synchronously; the store I/O and transport write happen after `next()`
|
|
327
|
+
returns (`destroy` supersedes a prior `regenerate`).
|
|
328
|
+
|
|
329
|
+
| Method | Returns | Summary |
|
|
330
|
+
| ------------ | ------- | --------------------------------------------------------------------------------- |
|
|
331
|
+
| `regenerate` | `void` | Mints a fresh id, carries the session's `state` over, and invalidates the old id. |
|
|
332
|
+
| `destroy` | `void` | Ends the session — deletes it from the store and clears its transport. |
|
|
333
|
+
|
|
334
|
+
#### `SessionStoreInterface`
|
|
335
|
+
|
|
336
|
+
The pluggable point-access persistence seam — `get`/`set`/`delete`, every
|
|
337
|
+
primitive async with a trailing injected `now`. `set` reads the id from the
|
|
338
|
+
session it is handed, so no separate id is passed. `DatabaseSessionStore`
|
|
339
|
+
takes its snapshot rebuild step as a constructor argument, and
|
|
340
|
+
`createDatabaseSessionStore` supplies `createRestoredSession` as that step.
|
|
341
|
+
|
|
342
|
+
| Method | Returns | Summary |
|
|
343
|
+
| -------- | ------------------------- | --------------------------------------------------------------------------- |
|
|
344
|
+
| `get` | `Promise<S \| undefined>` | Reads a session by id, applying the idle and absolute expiry against `now`. |
|
|
345
|
+
| `set` | `Promise<void>` | Persists a session under its own `id`, refreshing its idle window. |
|
|
346
|
+
| `delete` | `Promise<void>` | Removes a session by id — a no-op on an absent id, never throws. |
|
|
347
|
+
|
|
348
|
+
#### `SessionTransportInterface`
|
|
349
|
+
|
|
350
|
+
How a session id travels to and from the client — `read` is total (never
|
|
351
|
+
throws); `write`/`clear` mutate the returned `Response` on the way out (the
|
|
352
|
+
returning onion makes "before send" automatic).
|
|
353
|
+
|
|
354
|
+
| Method | Returns | Summary |
|
|
355
|
+
| ------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
356
|
+
| `read` | `string \| undefined \| Promise<...>` | Reads the incoming session id from the request — `undefined` on any failure. |
|
|
357
|
+
| `write` | `void \| Promise<void>` | Writes a freshly minted or regenerated session id onto the response, together with the request's encrypted-transport fact. |
|
|
358
|
+
| `clear` | `void` | Clears the transport's credential on `destroy()`. |
|
|
359
|
+
|
|
360
|
+
## Contract
|
|
361
|
+
|
|
362
|
+
These invariants hold across `src/core` / `src/server` ↔ `middleware.md`.
|
|
363
|
+
|
|
364
|
+
1. **Guide ↔ source bijection.** Every `function` / `class` / `interface` /
|
|
365
|
+
`type` / `const` row in the `## Surface` tables is a real export of its
|
|
366
|
+
source directory, and every export appears as a Surface row — exhaustive,
|
|
367
|
+
both directions.
|
|
368
|
+
2. **Guide ↔ source method bijection.** The `## Methods` tables list exactly
|
|
369
|
+
`AssetSourceInterface`'s, `SessionInterface`'s, `SessionControlInterface`'s,
|
|
370
|
+
`SessionStoreInterface`'s, and `SessionTransportInterface`'s public methods —
|
|
371
|
+
exhaustive, both directions.
|
|
372
|
+
|
|
373
|
+
### The ordering doctrine
|
|
374
|
+
|
|
375
|
+
The canonical onion, outermost first, and the failure each position
|
|
376
|
+
prevents:
|
|
377
|
+
|
|
378
|
+
3. **`createTelemetry` is outermost.** It sees the mapped status (after
|
|
379
|
+
`createBoundary` renders) and measures honest wall-clock duration —
|
|
380
|
+
anything mounted inside it would be excluded from the timing.
|
|
381
|
+
4. **`createCompression` sits outside `createBoundary`.** An error `Response`
|
|
382
|
+
the boundary renders from a caught throw still passes through
|
|
383
|
+
compression — mount it inside instead and error bodies ship uncompressed.
|
|
384
|
+
5. **`createBoundary` is the renderer.** Everything mounted beneath it may
|
|
385
|
+
throw `HTTPError` (or anything else) and have it mapped to a `Response`;
|
|
386
|
+
nothing below it needs its own try/catch.
|
|
387
|
+
6. **`createDeadline` sits inside the boundary.** A downstream `AbortError`
|
|
388
|
+
from the deadline firing is another throw the boundary maps cleanly
|
|
389
|
+
— mounting it outside would bypass that rendering.
|
|
390
|
+
7. **`createSecurity` is a documented tradeoff, not a fixed position.** The
|
|
391
|
+
canonical order places it inside the boundary, so an error `Response` a
|
|
392
|
+
handler explicitly returns still carries security headers, but an error
|
|
393
|
+
the boundary renders from a throw does not (`createSecurity`'s `next()`
|
|
394
|
+
never resolves for a throw beneath it). A consumer who wants headers on
|
|
395
|
+
every response, thrown errors included, mounts `createSecurity` above
|
|
396
|
+
`createBoundary` instead — both orders are legitimate; pick the one your
|
|
397
|
+
threat model wants.
|
|
398
|
+
8. **`createCors` claims preflights before the dispatcher's auto-`OPTIONS`.**
|
|
399
|
+
Its `OPTIONS` short-circuit runs before the terminal `Dispatcher.handle`
|
|
400
|
+
ever sees the request, by construction (any middleware position ahead of
|
|
401
|
+
the terminal works — CORS needs only array membership, not a fragile
|
|
402
|
+
slot).
|
|
403
|
+
9. **`createForwarded` resolves client facts before anything keys off
|
|
404
|
+
them.** `createLimiter`'s default key and any session/telemetry logic
|
|
405
|
+
that wants `ClientState.client.ip` must run downstream of it.
|
|
406
|
+
10. **`createETag` sits inside `createCompression`.** The hash is computed
|
|
407
|
+
over the uncompressed representation — hashing the compressed bytes
|
|
408
|
+
would break revalidation the instant the negotiated coding changes.
|
|
409
|
+
11. **`createBearer` sits before `createLimiter`.** The limiter's default key
|
|
410
|
+
derivation prefers `BearerState.token` (the `token:<value>` idiom) over
|
|
411
|
+
a client-IP fallback — bearer must have already stashed it.
|
|
412
|
+
12. **`createBody` sits before `createSession`/`createCSRF`.** `createSession`'s
|
|
413
|
+
async `mint` and `createCSRF`'s `_csrf` body-field read both need the
|
|
414
|
+
cached `context.body()` already resolved.
|
|
415
|
+
13. **`createSession` sits before `createCSRF`.** CSRF's session-binding —
|
|
416
|
+
the security acceptance bar's CSRF item — requires `context.state.session`
|
|
417
|
+
to already be stashed.
|
|
418
|
+
|
|
419
|
+
### The security acceptance bar, as documented behavior
|
|
420
|
+
|
|
421
|
+
14. **CORS.** `Vary: Origin` is merged only on the reflect (allow-list) path,
|
|
422
|
+
never on the `'*'` wildcard path; the literal `Origin: null` is never
|
|
423
|
+
reflected even when the string `'null'` is allow-listed.
|
|
424
|
+
15. **Headers.** A hostile `X-Request-ID` (off-charset, oversize, or
|
|
425
|
+
CRLF-bearing) is never echoed — `createSecurity` mints a fresh
|
|
426
|
+
`crypto.randomUUID()` instead; a custom `csp` string replaces the
|
|
427
|
+
default wholesale (never merges); `X-Content-Type-Options: nosniff` is
|
|
428
|
+
unconditional, with no opt-out.
|
|
429
|
+
16. **Bearer.** `verifyToken` is total over garbage, tampered, expired, or
|
|
430
|
+
empty-rotation input — every failure renders `401`, never a crash;
|
|
431
|
+
verification is constant-time through `crypto.subtle.verify`.
|
|
432
|
+
17. **Limiter.** The key derivation never reads `X-Forwarded-For` itself (only
|
|
433
|
+
`createForwarded`'s already-resolved `ClientState` does, when mounted) —
|
|
434
|
+
so an unmounted `createForwarded` leaves XFF completely untrusted;
|
|
435
|
+
same-socket requests with different XFF values still share one bucket
|
|
436
|
+
without it; IPv6 addresses collapse to their `/64` network through
|
|
437
|
+
`computeClientKey`; the exhausted check runs before `consume`, admitting
|
|
438
|
+
exactly `max` requests per window; capacity eviction is true LRU — every
|
|
439
|
+
access (not only insertion) refreshes a key's recency, so an attacker
|
|
440
|
+
re-requesting a hot key can never keep it evicted-and-reset; a bucket
|
|
441
|
+
evicted for capacity invokes the optional `evict` sink (throw-isolated).
|
|
442
|
+
18. **Body.** `createBody` maps a malformed-JSON `undefined` resolution to a
|
|
443
|
+
`400`; the size/decompression caps and the `__proto__`/`constructor`/
|
|
444
|
+
`prototype` scrub are the substrate's own `readBody` behavior
|
|
445
|
+
(`ServerOptions.limit`) — this battery only drives the cache eagerly and
|
|
446
|
+
maps its outcomes.
|
|
447
|
+
19. **Session.** The default `MemorySessionStore` enforces both an idle
|
|
448
|
+
timeout (`ttl`, lazy eviction on `get`) and an absolute lifetime
|
|
449
|
+
(`lifetime`, evicting even a continuously-touched session — `created`
|
|
450
|
+
is stamped once at first `set` and preserved across every later
|
|
451
|
+
re-persist); it is also capacity-capped (`capacity`, default
|
|
452
|
+
`DEFAULT_SESSION_CAPACITY`) and evicts the least-recently-written id —
|
|
453
|
+
every `set` (not `get`) refreshes recency — invoking the optional
|
|
454
|
+
`evict` sink (throw-isolated) on a capacity eviction or an expired-entry
|
|
455
|
+
prune, but never for an explicit `delete`; `control.regenerate()` rotates the id while carrying the
|
|
456
|
+
session's `state` over and invalidating the old id; a signed cookie
|
|
457
|
+
transport inherits the full substrate injection-hardening matrix
|
|
458
|
+
(`__Host-` spoof rejection, `Domain`/`Path` injection throws,
|
|
459
|
+
`SameSite=None` forces `Secure`, `Secure` derived from the connection's
|
|
460
|
+
TLS fact when omitted). The battery installs no route of its own: it
|
|
461
|
+
answers `404` when `required` is set and no session resolves or mints, and
|
|
462
|
+
a consumer ending a session on `DELETE` mounts its own handler over
|
|
463
|
+
`control.destroy()`.
|
|
464
|
+
20. **CSRF.** With a session ahead, the minted token is bound to that
|
|
465
|
+
session's id (`signToken(sessionId)`) — a mutating request's recovered
|
|
466
|
+
bound id must equal its own session's id, so a token minted under
|
|
467
|
+
session A replayed against session B is `403` even with matching
|
|
468
|
+
double-submit halves; without a session, `createCSRF` falls back to
|
|
469
|
+
signed-random double-submit (documented weaker — no cross-session
|
|
470
|
+
binding is possible without one).
|
|
471
|
+
21. **Static.** Every served response is opened as a `FileHandle` and its
|
|
472
|
+
headers (`Content-Length`/`ETag`) are computed from that same handle's
|
|
473
|
+
`fstat` — the bytes `streamFile` later reads can never diverge from the
|
|
474
|
+
headers already sent, closing the stat-to-stream TOCTOU. The traversal
|
|
475
|
+
guard's algorithm order is load-bearing —
|
|
476
|
+
strip `prefix` on a segment boundary, `decodeURIComponent` (refusing
|
|
477
|
+
malformed escapes), reject NUL, make relative before `normalize` (so a
|
|
478
|
+
leading `..` survives as a climbing segment), `normalize`, refuse a
|
|
479
|
+
Windows reserved-device segment (`NUL.json` refused, `nullable.css`
|
|
480
|
+
served), then `resolve` under `root` and require containment; a
|
|
481
|
+
multi-range or malformed `Range` header serves the full body (`200`),
|
|
482
|
+
never a partial guess; the SPA fallback shell path is a fixed,
|
|
483
|
+
non-user-controlled join — never re-run through the traversal resolver.
|
|
484
|
+
22. **Assets.** `createAssets` decodes a browser-relative key and refuses
|
|
485
|
+
malformed escapes, backslashes, empty segments, `.`/`..`, and dotfiles
|
|
486
|
+
before it calls `AssetSourceInterface.read`. A successful value is copied
|
|
487
|
+
and cached. A Brotli value is decompressed once; Brotli and identity share
|
|
488
|
+
the identity body's `computeBodyETag` validator and vary on
|
|
489
|
+
`Accept-Encoding`. Gzip is not offered. `Range` is ignored and serves the
|
|
490
|
+
selected complete representation as `200`.
|
|
491
|
+
23. **Multipart.** Type rejection applies only when `allowed` is configured:
|
|
492
|
+
a file whose sniffed (magic-byte) bytes detect no type on that list is
|
|
493
|
+
rejected `415`, and a signature-less file is always rejected because
|
|
494
|
+
sniffing cannot place it on the list. A declared `Content-Type`
|
|
495
|
+
disagreeing with the sniffed type is reported as `validated: false` and
|
|
496
|
+
is never rejected on its own — with no `allowed` list, a disagreement
|
|
497
|
+
produces a normal response. Staged temp filenames are `randomUUID()`, never
|
|
498
|
+
derived from the client-declared filename (traversal-by-filename is
|
|
499
|
+
impossible by construction); every limit trips mid-stream with already-
|
|
500
|
+
staged files cleaned up; a mid-upload client disconnect triggers the
|
|
501
|
+
same fail-closed cleanup; a preamble longer than
|
|
502
|
+
`MULTIPART_MAX_PREAMBLE` before the first boundary is rejected
|
|
503
|
+
`'malformed'` rather than scanned unbounded; an empty-filename part
|
|
504
|
+
with a zero-byte body (a file input submitted with no file chosen) is a
|
|
505
|
+
no-op — staged then discarded, never counted against `limits.file.count`,
|
|
506
|
+
never surfaced as an upload; staged files default to a
|
|
507
|
+
process-owned `mkdtemp` directory under `os.tmpdir()` locked to mode
|
|
508
|
+
`0o700`, with each staged file opened at mode `0o600` (both overridable
|
|
509
|
+
through `options.directory`).
|
|
510
|
+
24. **Boundary.** `expose: false` leaks nothing (a non-`HTTPError` throw's
|
|
511
|
+
message never reaches the body); an `HTTPError`'s own `message` always
|
|
512
|
+
surfaces (it is the handler's deliberate signal); a `report` sink's own
|
|
513
|
+
throw is swallowed and can never alter the response.
|
|
514
|
+
25. **`only`/`except` are not a security boundary.** Both match
|
|
515
|
+
`context.url.pathname` exactly — a trailing slash (`/login/` vs `/login`),
|
|
516
|
+
a case variant, or a percent-encoded path silently falls outside an
|
|
517
|
+
`only()`-scoped path set, and a security battery scoped that way goes
|
|
518
|
+
dark on that request with no signal. Prefer `except()` for security
|
|
519
|
+
batteries (CSRF, bearer, rate limiting) — its failure mode is fail-closed
|
|
520
|
+
(an unlisted or misspelled path still gets the battery; only the
|
|
521
|
+
explicitly excluded paths lose it), where `only()`'s failure mode is
|
|
522
|
+
fail-open. Whichever combinator is used, keep its path set in lockstep
|
|
523
|
+
with the router's actual routes — a route added after the fact and not
|
|
524
|
+
added to the set is silently unscoped.
|
|
525
|
+
|
|
526
|
+
## Patterns
|
|
527
|
+
|
|
528
|
+
### Canonical onion — fetch-native runtime
|
|
529
|
+
|
|
530
|
+
The full ordering doctrine, composed directly over `compose` (no `@orkestrel/server`
|
|
531
|
+
`Server` required — any fetch-native runtime works):
|
|
532
|
+
|
|
533
|
+
```ts
|
|
534
|
+
import {
|
|
535
|
+
createBearer,
|
|
536
|
+
createBody,
|
|
537
|
+
createBoundary,
|
|
538
|
+
createCompression,
|
|
539
|
+
createCors,
|
|
540
|
+
createCSRF,
|
|
541
|
+
createDeadline,
|
|
542
|
+
createETag,
|
|
543
|
+
createForwarded,
|
|
544
|
+
createLimiter,
|
|
545
|
+
createSecurity,
|
|
546
|
+
createSession,
|
|
547
|
+
createTelemetry,
|
|
548
|
+
createCookieTransport,
|
|
549
|
+
} from '@orkestrel/middleware'
|
|
550
|
+
import type {
|
|
551
|
+
BearerState,
|
|
552
|
+
ClientState,
|
|
553
|
+
CSRFState,
|
|
554
|
+
IdentifierState,
|
|
555
|
+
SessionState,
|
|
556
|
+
} from '@orkestrel/middleware'
|
|
557
|
+
import { compose } from '@orkestrel/server'
|
|
558
|
+
|
|
559
|
+
interface State extends BearerState, ClientState, CSRFState, IdentifierState, SessionState {
|
|
560
|
+
readonly connection?: { readonly ip?: string }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const onion = [
|
|
564
|
+
createTelemetry({ record: (entry) => console.log(entry) }),
|
|
565
|
+
createCompression(),
|
|
566
|
+
createBoundary({ expose: false }),
|
|
567
|
+
createDeadline({ ms: 5_000 }),
|
|
568
|
+
createSecurity({ hsts: true }),
|
|
569
|
+
createCors({ origin: ['https://app.example'] }),
|
|
570
|
+
createForwarded({ proxies: 1 }),
|
|
571
|
+
createETag(),
|
|
572
|
+
createBearer({ secret: 'shh' }),
|
|
573
|
+
createLimiter({ max: 100, window: 60_000 }),
|
|
574
|
+
createBody(),
|
|
575
|
+
createSession({ transport: createCookieTransport({ secret: 'shh' }) }),
|
|
576
|
+
createCSRF({ secret: 'shh' }),
|
|
577
|
+
]
|
|
578
|
+
|
|
579
|
+
const handle = compose<State>(onion, async (_request, context) => {
|
|
580
|
+
return Response.json({ session: context.state.session?.id })
|
|
581
|
+
})
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
### Canonical onion — behind `@orkestrel/server`
|
|
585
|
+
|
|
586
|
+
The fence hands the same chain to `createServer` as its `middleware` option, so the server owns the listening socket and the dispatcher the chain terminates in.
|
|
587
|
+
|
|
588
|
+
```ts
|
|
589
|
+
import { createBoundary, createSecurity } from '@orkestrel/middleware'
|
|
590
|
+
import type { IdentifierState } from '@orkestrel/middleware'
|
|
591
|
+
import { createServer } from '@orkestrel/server'
|
|
592
|
+
import { createDispatcher } from '@orkestrel/router'
|
|
593
|
+
|
|
594
|
+
interface State extends IdentifierState {}
|
|
595
|
+
|
|
596
|
+
const dispatcher = createDispatcher<State>()
|
|
597
|
+
dispatcher.add({ method: 'GET', path: '/health', handler: () => new Response('ok') })
|
|
598
|
+
|
|
599
|
+
const server = createServer<State>({
|
|
600
|
+
dispatcher,
|
|
601
|
+
state: () => ({}),
|
|
602
|
+
middleware: [createBoundary(), createSecurity()],
|
|
603
|
+
})
|
|
604
|
+
const port = await server.start()
|
|
605
|
+
await server.stop()
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
### Body: eager cache drive
|
|
609
|
+
|
|
610
|
+
The fence constructs the body battery with no options of its own.
|
|
611
|
+
|
|
612
|
+
```ts
|
|
613
|
+
import { createBody } from '@orkestrel/middleware'
|
|
614
|
+
|
|
615
|
+
const body = createBody() // no options — the seam's context.body() owns limits
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
`createBody` stashes a defined resolved value on `context.state.body` (and
|
|
619
|
+
leaves the optional property absent when resolution yields `undefined`), so its
|
|
620
|
+
`TState` must extend `BodyState`. Zero-annotation usage (`createBody()`) infers
|
|
621
|
+
`BodyState` by default. An explicitly-typed chain state
|
|
622
|
+
(`createBody<SomeState>()`) must include the `BodyState` slice —
|
|
623
|
+
`SomeState & BodyState`, or `SomeState` extending `BodyState` — unless it
|
|
624
|
+
already carries a `body` field.
|
|
625
|
+
|
|
626
|
+
### Session: control handle, header transport, injected store
|
|
627
|
+
|
|
628
|
+
The fence wires the session battery to a header transport and an injected memory store, then rotates and ends the session through the control handle a downstream handler reads.
|
|
629
|
+
|
|
630
|
+
```ts
|
|
631
|
+
import {
|
|
632
|
+
createHeaderTransport,
|
|
633
|
+
createMemorySessionStore,
|
|
634
|
+
createSession,
|
|
635
|
+
} from '@orkestrel/middleware'
|
|
636
|
+
import type { SessionState } from '@orkestrel/middleware'
|
|
637
|
+
|
|
638
|
+
interface State extends SessionState {}
|
|
639
|
+
|
|
640
|
+
const store = createMemorySessionStore({ ttl: 900_000, lifetime: 86_400_000 })
|
|
641
|
+
const session = createSession<import('@orkestrel/middleware').SessionInterface, State>({
|
|
642
|
+
transport: createHeaderTransport({ header: 'session-id' }),
|
|
643
|
+
store,
|
|
644
|
+
mint: () => true,
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
// Inside a handler downstream of `session`:
|
|
648
|
+
declare const context: { readonly state: State }
|
|
649
|
+
context.state.control?.regenerate() // rotate the id after a privilege change (anti-fixation)
|
|
650
|
+
context.state.control?.destroy() // end the session outright
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
### Session store seam — direct calls
|
|
654
|
+
|
|
655
|
+
The fence drives a memory store's `set`, `get`, and `delete` methods directly, with no middleware chain around them.
|
|
656
|
+
|
|
657
|
+
```ts
|
|
658
|
+
import { createMemorySessionStore } from '@orkestrel/middleware'
|
|
659
|
+
import { Session } from '@orkestrel/middleware'
|
|
660
|
+
|
|
661
|
+
const store = createMemorySessionStore({ ttl: 60_000 })
|
|
662
|
+
const now = Date.now()
|
|
663
|
+
await store.set(new Session('id-1'), now)
|
|
664
|
+
await store.get('id-1', now) // resolves the session, or undefined if expired
|
|
665
|
+
await store.delete('id-1') // no-op on an already-absent id
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
### Session store seam — durable database-backed store
|
|
669
|
+
|
|
670
|
+
The `@orkestrel/database` peer is optional and type-only inside this
|
|
671
|
+
package's `src` — a memory-only consumer installs nothing extra. An app that
|
|
672
|
+
wants durable sessions installs `@orkestrel/database` itself, declares a
|
|
673
|
+
table with `sessionColumns`, and passes the open table + a guard to
|
|
674
|
+
`createDatabaseSessionStore`:
|
|
675
|
+
|
|
676
|
+
```ts
|
|
677
|
+
import {
|
|
678
|
+
createDatabaseSessionStore,
|
|
679
|
+
isSession,
|
|
680
|
+
Session,
|
|
681
|
+
sessionColumns,
|
|
682
|
+
} from '@orkestrel/middleware'
|
|
683
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
684
|
+
|
|
685
|
+
const db = createDatabase({ driver: createMemoryDriver(), tables: { sessions: sessionColumns } })
|
|
686
|
+
const store = createDatabaseSessionStore(db.table('sessions'), isSession, { ttl: 900_000 })
|
|
687
|
+
const now = Date.now()
|
|
688
|
+
await store.set(new Session('id-1'), now)
|
|
689
|
+
await store.get('id-1', now) // resolves the session, or undefined if expired/removed
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
The `session` column holds the `SessionSnapshot` JSON that `snapshotSession`
|
|
693
|
+
writes and `createRestoredSession` reads back: `{ id, state }`, where `state`
|
|
694
|
+
carries the session's entries. The cursor columns are `seen` and `created`. A
|
|
695
|
+
table still declared with the earlier `lastSeen` and `createdAt` columns fails
|
|
696
|
+
closed rather than reading a stale row as live: the table's own read guard
|
|
697
|
+
refuses a row that does not satisfy `sessionColumns`, `store.get` resolves
|
|
698
|
+
`undefined`, and the row stays in place until the table is migrated or
|
|
699
|
+
recreated.
|
|
700
|
+
|
|
701
|
+
### Session transport seam — direct calls
|
|
702
|
+
|
|
703
|
+
The fence drives a header transport's `read`, `write`, and `clear` methods over a real `Request` and `Response`.
|
|
704
|
+
|
|
705
|
+
```ts
|
|
706
|
+
import { createHeaderTransport } from '@orkestrel/middleware'
|
|
707
|
+
|
|
708
|
+
const transport = createHeaderTransport()
|
|
709
|
+
const request = new Request('https://x', { headers: { 'session-id': 'abc' } })
|
|
710
|
+
await transport.read(request) // 'abc'
|
|
711
|
+
const response = new Response('ok')
|
|
712
|
+
await transport.write(response, 'abc', false) // sets the session-id header
|
|
713
|
+
transport.clear(response) // removes it
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
### CSRF: session-bound double-submit
|
|
717
|
+
|
|
718
|
+
The fence mounts the CSRF battery behind a cookie-transport session, which is what binds each minted token to that session's id.
|
|
719
|
+
|
|
720
|
+
```ts
|
|
721
|
+
import { createCSRF, createSession, createCookieTransport } from '@orkestrel/middleware'
|
|
722
|
+
import type { CSRFState, SessionState } from '@orkestrel/middleware'
|
|
723
|
+
|
|
724
|
+
interface State extends SessionState, CSRFState {}
|
|
725
|
+
|
|
726
|
+
const session = createSession<import('@orkestrel/middleware').SessionInterface, State>({
|
|
727
|
+
transport: createCookieTransport({ secret: 'session-secret' }),
|
|
728
|
+
})
|
|
729
|
+
const csrf = createCSRF({ secret: 'csrf-secret' }) // session ahead binds the token to its id
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
### Multipart: node face, sniffed-type allow-list
|
|
733
|
+
|
|
734
|
+
The fence mounts the node-face multipart battery with a sniffed-type allow-list and narrows its parsed result on `context.state` through the shipped guards.
|
|
735
|
+
|
|
736
|
+
```ts
|
|
737
|
+
import { createMultipart } from '@orkestrel/middleware/server'
|
|
738
|
+
import { isMultipartBody, isMultipartFile } from '@orkestrel/middleware'
|
|
739
|
+
import type { MultipartState } from '@orkestrel/middleware'
|
|
740
|
+
|
|
741
|
+
interface State extends MultipartState {}
|
|
742
|
+
|
|
743
|
+
const uploads = createMultipart<State>({ allowed: ['image/png', 'image/jpeg'] })
|
|
744
|
+
|
|
745
|
+
declare const context: { readonly state: State }
|
|
746
|
+
if (isMultipartBody(context.state.multipart)) {
|
|
747
|
+
context.state.multipart.files // narrowed, ready to stream/read/move
|
|
748
|
+
for (const files of Object.values(context.state.multipart.files)) {
|
|
749
|
+
files.every((file) => isMultipartFile(file)) // true — every entry is a staged MultipartFile
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
### Multipart limits — direct resolution
|
|
755
|
+
|
|
756
|
+
The fence resolves one partial limits input into the effective caps, with no battery around it.
|
|
757
|
+
|
|
758
|
+
```ts
|
|
759
|
+
import { resolveMultipartLimits } from '@orkestrel/middleware/server'
|
|
760
|
+
|
|
761
|
+
resolveMultipartLimits({ file: { size: 1_048_576 } }) // fills in every other default cap
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
Multipart processing reports no progress.
|
|
765
|
+
|
|
766
|
+
### Assets: in-memory source
|
|
767
|
+
|
|
768
|
+
The fence implements an `AssetSourceInterface` over a single in-memory entry and hands it to `createAssets`.
|
|
769
|
+
|
|
770
|
+
```ts
|
|
771
|
+
import type { AssetSourceInterface } from '@orkestrel/middleware/server'
|
|
772
|
+
import { createAssets } from '@orkestrel/middleware/server'
|
|
773
|
+
|
|
774
|
+
const source: AssetSourceInterface = {
|
|
775
|
+
read(path) {
|
|
776
|
+
return path === 'index.html'
|
|
777
|
+
? { body: new TextEncoder().encode('<!doctype html><title>App</title>') }
|
|
778
|
+
: undefined
|
|
779
|
+
},
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const serveAssets = createAssets({ source })
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
Return `{ body, encoding: 'br' }` when `body` contains Brotli bytes.
|
|
786
|
+
`createAssets` keeps those bytes for Brotli clients and caches one identity
|
|
787
|
+
decompression for every other client. Both responses share an identity-body
|
|
788
|
+
ETag. Gzip is not offered.
|
|
789
|
+
|
|
790
|
+
`read` must answer a bounded key set and return `undefined` for every key
|
|
791
|
+
outside it. `createAssets` retains every successful result for the factory's
|
|
792
|
+
lifetime and evicts nothing, so a `read` that synthesizes a representation for
|
|
793
|
+
an arbitrary key grows that cache without limit under request pressure. A miss
|
|
794
|
+
is never retained, so an absent key is read again on its next request.
|
|
795
|
+
|
|
796
|
+
### Static: SPA fallback
|
|
797
|
+
|
|
798
|
+
The fence serves a directory over `node:fs` with the SPA fallback on, which answers an eligible miss with the shell.
|
|
799
|
+
|
|
800
|
+
```ts
|
|
801
|
+
import { createStatic } from '@orkestrel/middleware/server'
|
|
802
|
+
|
|
803
|
+
const serveApp = createStatic({ root: '/srv/public', fallback: true }) // excludes '/api' by default
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
An eligible miss is a `GET` or `HEAD` request for an extensionless pathname
|
|
807
|
+
outside the excluded prefix whose `Accept` admits `text/html`. It answers with
|
|
808
|
+
`index` through the same opened-handle `fstat` header block a directly
|
|
809
|
+
requested file answers through. `Cache-Control`, `ETag`, `Content-Length`, and
|
|
810
|
+
`Accept-Ranges` therefore carry the shell's own facts, `If-None-Match`
|
|
811
|
+
revalidates to `304`, and `Range` serves `206` — identically on both routes. A
|
|
812
|
+
`HEAD` navigation resolves the same shell as its `GET` and answers `200` with
|
|
813
|
+
those headers and no body.
|
|
814
|
+
|
|
815
|
+
The shell path is a fixed `root`-plus-`index` join rather than a request-derived
|
|
816
|
+
path, so the `dotfiles` policy screens the request pathname alone. A dotfile
|
|
817
|
+
`index` is refused when it is requested directly under `dotfiles: 'deny'` and is
|
|
818
|
+
still served through the fallback, because the operator configured that path.
|
|
819
|
+
|
|
820
|
+
### Seam adaptations — read before wiring sessions or multipart
|
|
821
|
+
|
|
822
|
+
- **`createBody` carries no `limit`/`decompression` options.** The shipped
|
|
823
|
+
`MiddlewareContext.body()` is a parameterless, server-owned cache
|
|
824
|
+
(`ServerOptions.limit` governs its size cap) — this battery eagerly
|
|
825
|
+
awaits it and maps its outcomes (a `ContentTooLargeError`/`HTTPError`
|
|
826
|
+
propagates untouched; `undefined` under a declared `application/json`
|
|
827
|
+
maps to `400`).
|
|
828
|
+
- **`createMultipart` consumes `request.body` as a stream — never
|
|
829
|
+
`context.body()`.** Its parsed result is stashed on
|
|
830
|
+
`context.state.multipart`, narrowed with `isMultipartBody`. After it
|
|
831
|
+
runs, `context.body()` must not be called for that request — the
|
|
832
|
+
underlying stream is exhausted.
|
|
833
|
+
- **`SessionTransportInterface.write`/`clear` mutate the returned `Response` on the
|
|
834
|
+
way out.** `createSession` applies store I/O and transport writes after
|
|
835
|
+
`next()` resolves: `destroy()` → `store.delete` + `transport.clear`;
|
|
836
|
+
`regenerate()` → `store.set` the new session, `store.delete` the old,
|
|
837
|
+
`transport.write` the new id; otherwise → `store.set` the resolved/minted
|
|
838
|
+
session, `transport.write` only when freshly minted. `destroy()`
|
|
839
|
+
supersedes a prior `regenerate()`.
|
|
840
|
+
|
|
841
|
+
### Practices
|
|
842
|
+
|
|
843
|
+
- **Mount `createTelemetry` and `createCompression` outside `createBoundary`**
|
|
844
|
+
— error bodies still compress, and duration still measures the whole
|
|
845
|
+
onion (Contract §3–4).
|
|
846
|
+
- **Mount `createForwarded` before anything that keys off `ClientState`** —
|
|
847
|
+
`createLimiter`'s default key and any client-IP-sensitive logic
|
|
848
|
+
downstream (Contract §9).
|
|
849
|
+
- **Never derive a rate-limit key from `X-Forwarded-For` yourself** — mount
|
|
850
|
+
`createForwarded` and let its resolved `ClientState` do it (Contract §17).
|
|
851
|
+
- **Call `control.regenerate()` on every privilege change** (login,
|
|
852
|
+
elevation) — the OWASP anti-fixation requirement session-based auth
|
|
853
|
+
depends on.
|
|
854
|
+
- **Install a `report` sink on `createBoundary`** for observability — its
|
|
855
|
+
own throw is swallowed, so it can never crash a response.
|
|
856
|
+
- **Pick your `createSecurity` position deliberately** — inside the
|
|
857
|
+
boundary (default; headers only on returned responses) or above it
|
|
858
|
+
(headers on every response, thrown errors included) — see Contract §7.
|
|
859
|
+
|
|
860
|
+
## Tests
|
|
861
|
+
|
|
862
|
+
- [`tests/guides.test.ts`](../tests/guides.test.ts) — the `## Surface` ↔ `src/core` +
|
|
863
|
+
`src/server` bijection (value and type exports), the `AssetSourceInterface`,
|
|
864
|
+
`SessionInterface`, `SessionControlInterface`, `SessionStoreInterface`, and
|
|
865
|
+
`SessionTransportInterface` method bijections, and the equality gate: every `Summary`
|
|
866
|
+
cell against its declaration's description paragraph, the titled `Mount a battery`
|
|
867
|
+
fence against the `@example` block of that title (pinned so the titled pair cannot be
|
|
868
|
+
retired silently), and the README pitch against this guide's tagline.
|
|
869
|
+
- [`tests/src/core/helpers.test.ts`](../tests/src/core/helpers.test.ts) —
|
|
870
|
+
`resolveKey` precedence, `buildRetryAfter`/`buildRateLimitField`/
|
|
871
|
+
`buildRateLimitPolicyField` exact wire strings, `matchesTrustedEntry`/
|
|
872
|
+
`resolveForwardedFor` matrices, `detectEncodings`, `compressBytes`, buffering-eligibility
|
|
873
|
+
predicates, `transferSessionState`, `isPreflight`, `buildClient`,
|
|
874
|
+
`validateSessionLimits`.
|
|
875
|
+
- [`tests/src/core/validators.test.ts`](../tests/src/core/validators.test.ts) —
|
|
876
|
+
the `isSession`/`isSessionControl`/`isMultipartFile`/`isMultipartBody`
|
|
877
|
+
totality guards, each driven over a well-shaped value and over the hostile
|
|
878
|
+
inputs it must answer `false` to.
|
|
879
|
+
- [`tests/src/core/Session.test.ts`](../tests/src/core/Session.test.ts) —
|
|
880
|
+
the entity shape (`id`, an independent `state` view and its mutators per instance).
|
|
881
|
+
- [`tests/src/core/stores/MemorySessionStore.test.ts`](../tests/src/core/stores/MemorySessionStore.test.ts) —
|
|
882
|
+
construction guards, get/set/delete, idle + absolute-lifetime eviction,
|
|
883
|
+
`created` stamped once and preserved across re-set.
|
|
884
|
+
- [`tests/src/core/stores/DatabaseSessionStore.test.ts`](../tests/src/core/stores/DatabaseSessionStore.test.ts) —
|
|
885
|
+
get/set/delete over a real `@orkestrel/database` memory-driver table, idle +
|
|
886
|
+
absolute-lifetime eviction (including the underlying row's removal),
|
|
887
|
+
`created` stamped once and preserved across re-set, guard rejection,
|
|
888
|
+
construction guards, rebuild through the injected restore step, and the
|
|
889
|
+
fail-closed read of a row stored under the earlier cursor columns.
|
|
890
|
+
- [`tests/src/core/factories.test.ts`](../tests/src/core/factories.test.ts) —
|
|
891
|
+
`createCookieTransport`/`createHeaderTransport` round-trips over real
|
|
892
|
+
`Request`/`Response`, `createMemorySessionStore` shallow mirror,
|
|
893
|
+
`createRestoredSession` snapshot rebuilds and malformed refusals,
|
|
894
|
+
`createDatabaseSessionStore` construction guard.
|
|
895
|
+
- [`tests/src/core/middlewares.test.ts`](../tests/src/core/middlewares.test.ts) —
|
|
896
|
+
every battery's defaults, options, skip conditions, and the security
|
|
897
|
+
acceptance bar's invariants; the canonical onion composed end-to-end.
|
|
898
|
+
- [`tests/src/server/helpers.test.ts`](../tests/src/server/helpers.test.ts) —
|
|
899
|
+
traversal and SPA-fallback resolution, byte-signature matching, node zlib
|
|
900
|
+
compression, multipart limit resolution, boundary extraction, part-header
|
|
901
|
+
parsing, `streamFile`'s pull-driven backpressure and descriptor release, and
|
|
902
|
+
the uploaded-file operations, including `moveUploadedFile`'s cross-device
|
|
903
|
+
fallback where a runtime device probe finds a second filesystem.
|
|
904
|
+
- [`tests/src/server/parsers.test.ts`](../tests/src/server/parsers.test.ts) —
|
|
905
|
+
`parseMultipartRequest` end to end: field and file staging, dangerous-key
|
|
906
|
+
refusal, the sniff-authoritative allow-list, every limit and its boundary,
|
|
907
|
+
the malformed matrix, reader cancellation, abort mid-upload, and the staged
|
|
908
|
+
file permission bits.
|
|
909
|
+
- [`tests/src/server/MultipartParser.test.ts`](../tests/src/server/MultipartParser.test.ts) —
|
|
910
|
+
the interned state machine driven directly: the preamble cap, the
|
|
911
|
+
header-block cap, the total-bytes cap, the abort-mid-upload path, and the
|
|
912
|
+
staged-file cleanup each throw performs.
|
|
913
|
+
- [`tests/src/server/middlewares.test.ts`](../tests/src/server/middlewares.test.ts) —
|
|
914
|
+
in-memory asset ownership, key refusal, identity/Brotli negotiation,
|
|
915
|
+
conditional and HEAD responses, filesystem static serving, multipart
|
|
916
|
+
middleware, and the server-face composition.
|
|
917
|
+
|
|
918
|
+
## See also
|
|
919
|
+
|
|
920
|
+
- [`AGENTS.md`](../AGENTS.md) — the coding rules this package is written under.
|
|
921
|
+
- `@orkestrel/server` — the frozen seam and substrate every battery in this
|
|
922
|
+
package is built over. Its mirrored guide is [`server.md`](server.md).
|
|
923
|
+
- `@orkestrel/contract` — the guards backing every construction boundary.
|
|
924
|
+
- `@orkestrel/budget` — `createLimiter`'s per-key tally.
|
|
925
|
+
- `@orkestrel/abort` / `@orkestrel/timeout` — `createDeadline`'s
|
|
926
|
+
signal-linking and timer.
|
|
927
|
+
- [`README.md`](README.md) — the guides index.
|