expediate 1.0.5 → 1.0.6

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/CONTRIBUTING.md +150 -0
  3. package/README.md +278 -779
  4. package/dist/apis.d.ts +372 -12
  5. package/dist/apis.d.ts.map +1 -1
  6. package/dist/apis.js +483 -65
  7. package/dist/apis.js.map +1 -1
  8. package/dist/cjs/index.js +2290 -807
  9. package/dist/git.d.ts +1 -1
  10. package/dist/git.d.ts.map +1 -1
  11. package/dist/git.js +5 -5
  12. package/dist/git.js.map +1 -1
  13. package/dist/http-objects.d.ts +26 -0
  14. package/dist/http-objects.d.ts.map +1 -0
  15. package/dist/http-objects.js +588 -0
  16. package/dist/http-objects.js.map +1 -0
  17. package/dist/index.d.ts +6 -5
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/jwt-auth.d.ts +11 -0
  22. package/dist/jwt-auth.d.ts.map +1 -1
  23. package/dist/jwt-auth.js +9 -9
  24. package/dist/jwt-auth.js.map +1 -1
  25. package/dist/middleware.js +2 -2
  26. package/dist/middleware.js.map +1 -1
  27. package/dist/mimetypes.json +882 -1
  28. package/dist/misc.d.ts +161 -25
  29. package/dist/misc.d.ts.map +1 -1
  30. package/dist/misc.js +228 -80
  31. package/dist/misc.js.map +1 -1
  32. package/dist/openapi.d.ts +156 -13
  33. package/dist/openapi.d.ts.map +1 -1
  34. package/dist/openapi.js +214 -71
  35. package/dist/openapi.js.map +1 -1
  36. package/dist/router-types.d.ts +760 -0
  37. package/dist/router-types.d.ts.map +1 -0
  38. package/dist/router-types.js +23 -0
  39. package/dist/router-types.js.map +1 -0
  40. package/dist/router.d.ts +7 -530
  41. package/dist/router.d.ts.map +1 -1
  42. package/dist/router.js +128 -375
  43. package/dist/router.js.map +1 -1
  44. package/dist/static.d.ts +2 -2
  45. package/dist/static.d.ts.map +1 -1
  46. package/dist/static.js +77 -22
  47. package/dist/static.js.map +1 -1
  48. package/docs/THREAT_MODEL.md +52 -0
  49. package/docs/api-builder-v2-design.md +644 -0
  50. package/docs/api-builder-v3-design.md +397 -0
  51. package/docs/api-builder.md +454 -0
  52. package/docs/benchmark.md +27 -0
  53. package/docs/body-parsing.md +223 -0
  54. package/docs/errors.md +359 -0
  55. package/docs/expediate.png +0 -0
  56. package/docs/git.md +139 -0
  57. package/docs/jwt-auth.md +251 -0
  58. package/docs/logo.svg +12 -0
  59. package/docs/middleware.md +264 -0
  60. package/docs/openapi.md +180 -0
  61. package/docs/router.md +356 -0
  62. package/docs/static.md +128 -0
  63. package/docs/wiki.json +123 -0
  64. package/package.json +30 -8
  65. package/dist/cjs/apis.js +0 -327
  66. package/dist/cjs/git.js +0 -293
  67. package/dist/cjs/jwt-auth.js +0 -532
  68. package/dist/cjs/middleware.js +0 -511
  69. package/dist/cjs/mimetypes.json +0 -1
  70. package/dist/cjs/misc.js +0 -787
  71. package/dist/cjs/openapi.js +0 -485
  72. package/dist/cjs/router.js +0 -898
  73. package/dist/cjs/static.js +0 -669
@@ -0,0 +1,397 @@
1
+ # API Builder v3 — Response Control, Auth Escape Hatches, and Error Unification
2
+
3
+ **Status:** Draft / design discussion
4
+ **Date:** 2026-06-25
5
+ **Scope:** `src/apis.ts`, `src/http-objects.ts`, touch points with `src/router.ts`
6
+ **Compatibility:** additive except where noted; one optional breaking change is flagged separately
7
+
8
+ ---
9
+
10
+ ## 1. Context
11
+
12
+ A second field study — independent from the DevLab review behind
13
+ `api-builder-v2-design.md` — evaluated `apiBuilder` for a project built around
14
+ hand-rolled, cookie-based session authentication (`createSessionMiddleware`
15
+ reading `req.headers.cookie` / writing `res.setHeader('Set-Cookie', ...)` /
16
+ attaching `req.session`) and a class-based `AppError` hierarchy
17
+ (`.status`/`.code`, caught once via `instanceof AppError`). The project ended
18
+ up using plain `createRouter()` routers instead of `apiBuilder`, for three
19
+ reasons:
20
+
21
+ 1. `ApiContext` carries no path to the response object, so nothing in the
22
+ `apiBuilder` pipeline can refresh a sliding-expiration session cookie or
23
+ otherwise write a header mid-pipeline.
24
+ 2. The truthy/falsy return convention (`200` / `201` no-body) doesn't cover
25
+ `201 Created` *with* a body, or `204 No Content` on `DELETE` — both
26
+ needed routes existed.
27
+ 3. `ApiError` is an anonymous `{ status, data, message }` shape; bridging it
28
+ to an `AppError` class hierarchy with `.code` would mean writing the same
29
+ `instanceof AppError` translation twice — once for `service.onError`, once
30
+ for the app's own `router.error()` (for the routes that aren't behind
31
+ `apiBuilder`).
32
+
33
+ A fourth complaint — that the `scope`/`data`/`methods` instance model is
34
+ incompatible with a pure-function, closure-injected style — does **not**
35
+ require a framework change. All three fields are optional; a service with
36
+ none of them is a singleton instance (`{ $key: 'singleton' }`) that handlers
37
+ are free to ignore entirely. Arrow-function handlers closing over
38
+ injected dependencies already work today and are shown in
39
+ `api-builder-v2-design.md` §9 (`wikiController = (wikiService) =>
40
+ defineController({ ... })`). The actual gap is that `docs/api-builder.md`
41
+ opens with the `data()`/`this`/`methods` example, which reads as if the
42
+ instance model were mandatory. That is a documentation fix (§5 below), not a
43
+ design problem.
44
+
45
+ ## 2. Pain points (evidence)
46
+
47
+ ### PP1 — No write path to the response inside the pipeline
48
+
49
+ Guards already receive `(ctx, req)` — enough to read `req.session` or
50
+ `req.headers['x-cinema-id']` and turn `requireRole`/`resolveCinemaId` into
51
+ ordinary guards. What guards (and `auth.check`) cannot do is **write** to the
52
+ response: there is no `res` anywhere in the guard or auth-check signatures,
53
+ so a sliding-expiration cookie refresh, or any header that must be set
54
+ *before* the handler runs, has no seam. Separately, `ctx.user` is hardwired
55
+ to `req.user` in `apis.ts` (`user: req.user` in `buildRoutes`), which is a
56
+ JWT-plugin convention (`req.user` is typed `TokenPayload` via module
57
+ augmentation in `jwt-auth.ts`) — a session-based identity has to either
58
+ write into `req.user` itself (works, but fights the JWT typing) or be
59
+ inaccessible from `ctx.user`.
60
+
61
+ ### PP2 — Binary return contract
62
+
63
+ Truthy → `200`, falsy → what the docs call "`201 No Content`" — which is
64
+ itself a minor existing inaccuracy: `201 Created` conventionally carries a
65
+ representation of the created resource; a bodyless success is `204 No
66
+ Content`. The binary convention has no slot for `201 Created` *with* a body
67
+ on `POST`, or an explicit `204` on `DELETE` alongside a plain `200` on `GET`
68
+ — exactly the three-way mapping a typical REST resource needs.
69
+
70
+ ### PP3 — Disconnected error hierarchies
71
+
72
+ `service.onError(err, ctx, req)` can reshape any thrown value into an
73
+ `ApiError`, but only for routes registered through that one `apiBuilder`
74
+ call. An application also has routes outside `apiBuilder` (static files,
75
+ the JWT plugin's `/auth/*` endpoints, ad hoc routers) whose failures flow
76
+ through `router.error()` / `onError()` instead — a structurally different
77
+ hook (`(err, req, res, next)` vs `(err, ctx, req) => void | ApiError`). A
78
+ custom `Error` subclass hierarchy with its own `.status`/`.code` has to be
79
+ translated at both seams, by hand, with no shared code between them.
80
+
81
+ ## 3. Goals and non-goals
82
+
83
+ **Goals**
84
+
85
+ 1. Give the pipeline (guards, `auth.check`) a way to reach the response
86
+ object for the narrow set of cases that need it (cookie/header
87
+ mid-pipeline writes), without reintroducing `res` into the handler's
88
+ pure-business-logic contract.
89
+ 2. Let a handler opt into an explicit status code (and, for `201`, a body)
90
+ on a per-call basis, without changing the default truthy/falsy
91
+ convention for the common case.
92
+ 3. Let one error-translation function serve both `apiBuilder` and
93
+ plain-router error handling, so a custom error hierarchy is mapped to
94
+ `ApiError` exactly once.
95
+ 4. Decouple `ctx.user` population from the `req.user` / JWT convention.
96
+
97
+ **Non-goals**
98
+
99
+ - Reopening `ctx.req`/`ctx.res` as handler-level fields. The v2 design
100
+ rejected this for handlers ("handlers that need raw objects should be
101
+ plain router middleware") and that reasoning still holds for the
102
+ *handler* layer — the routes that actually need raw `req`/`res` access
103
+ in this field study are auth/session concerns, which already run as
104
+ guards or `authenticate` middleware, not as `apiBuilder` route handlers.
105
+ This document proposes giving **guards and `auth.check`** a path to
106
+ `res` (§4), which is narrower and keeps the handler contract intact. If
107
+ a future case needs `res` *inside a handler* specifically, that should be
108
+ reconsidered on its own evidence rather than folded in here.
109
+ - A generic response-shaping/serialization hook. `withStatus()` (§5) covers
110
+ status + body; content negotiation, headers, streaming, etc. remain a job
111
+ for plain middleware in front of or instead of `apiBuilder`.
112
+ - Changing the default falsy-return status from `201` to the more correct
113
+ `204` as part of this round — flagged in §7 as a separate, optional
114
+ breaking change for the maintainer to schedule deliberately.
115
+
116
+ ---
117
+
118
+ ## 4. P1 — Reaching the response from guards and `auth.check`
119
+
120
+ ### `req.res` linking
121
+
122
+ Node's `http.ServerResponse` already exposes `res.req` back to the request
123
+ that produced it; expediate does not currently set the mirror `req.res`.
124
+ Adding it is a one-line change in `updateHttpObjects()` (`src/http-objects.ts`)
125
+ plus a type augmentation, and immediately gives every function that already
126
+ receives `req` — guards, and `auth.check` once it gains a `req` parameter
127
+ (below) — a path to `res` with no new parameter threaded through the whole
128
+ pipeline:
129
+
130
+ ```ts
131
+ // src/http-objects.ts, inside updateHttpObjects()
132
+ rReq.res = res;
133
+ ```
134
+
135
+ ```ts
136
+ // router-types.ts (or wherever RouterRequest is declared)
137
+ interface RouterRequest {
138
+ // ...
139
+ /** Back-reference to the response for this request, mirroring Node's `res.req`. */
140
+ res?: RouterResponse;
141
+ }
142
+ ```
143
+
144
+ ### `auth.check` gains `req`
145
+
146
+ `AuthBinding.check` currently has no `req` parameter at all — guards get
147
+ `(ctx, req)`, but the primary authorization hook doesn't, which is an
148
+ inconsistency independent of this field study. Adding it (as a third,
149
+ optional-to-use parameter) is additive:
150
+
151
+ ```ts
152
+ export interface AuthBinding<TUser = unknown> {
153
+ authenticate?: Middleware;
154
+ check?: (ctx: ApiContext<TUser>, required: string[], req: RouterRequest) => void | Promise<void>;
155
+ scheme?: Record<string, unknown>;
156
+ permissionsExtension?: string;
157
+ }
158
+ ```
159
+
160
+ A resource-scoped check that needs to slide a session's expiry on every
161
+ authorized request can now do so:
162
+
163
+ ```ts
164
+ auth: {
165
+ authenticate: sessionMiddleware,
166
+ check: async (ctx, required, req) => {
167
+ if (!req.session?.userId) throw { status: 401, message: 'Authentication required' };
168
+ requireCinemaAccess(req.session, req.headers['x-cinema-id'] as string, required);
169
+ req.res?.cookie('sid', req.session.id, { signed: true, maxAge: 3600 }); // slide expiry
170
+ },
171
+ },
172
+ ```
173
+
174
+ This is deliberately presented as an advanced path, not the default one:
175
+ guards and `check` should still prefer **throw / return state / return
176
+ void** wherever possible (see `docs/errors.md` and `api-builder-v2-design.md`
177
+ §5) — reaching into `req.res` bypasses that declarative contract and should
178
+ be reserved for cases with no other seam, exactly like the session-cookie
179
+ refresh above.
180
+
181
+ ### `resolveUser` — decoupling `ctx.user` from `req.user`
182
+
183
+ ```ts
184
+ export interface AuthBinding<TUser = unknown> {
185
+ // ...
186
+ /**
187
+ * Extract the authenticated identity for `ctx.user`. Defaults to
188
+ * `(req) => req.user`, the JWT-plugin convention. Override when the
189
+ * identity lives elsewhere (e.g. `req.session.user`).
190
+ */
191
+ resolveUser?: (req: RouterRequest) => TUser | undefined;
192
+ }
193
+ ```
194
+
195
+ `buildRoutes` reads `service.auth?.resolveUser?.(req) ?? req.user` instead of
196
+ the hardcoded `req.user`. A session-based `authenticate` middleware no longer
197
+ needs to write into the JWT-typed `req.user` field just to populate
198
+ `ctx.user`:
199
+
200
+ ```ts
201
+ auth: {
202
+ authenticate: sessionMiddleware,
203
+ resolveUser: (req) => req.session?.user,
204
+ },
205
+ ```
206
+
207
+ ---
208
+
209
+ ## 5. P2 — `withStatus()`: an opt-in response-status escape hatch
210
+
211
+ ```ts
212
+ const API_RESULT: unique symbol = Symbol('expediate.apiResult');
213
+
214
+ export interface ApiResult<T = unknown> {
215
+ readonly [API_RESULT]: true;
216
+ status: number;
217
+ body?: T;
218
+ }
219
+
220
+ /**
221
+ * Return from a service method to send a specific status code (and,
222
+ * optionally, a JSON body) instead of the default truthy → 200 / falsy → 201
223
+ * convention. Useful for `201 Created` with a body, or an explicit `204 No
224
+ * Content`.
225
+ */
226
+ export function withStatus<T>(status: number, body?: T): ApiResult<T> {
227
+ return { [API_RESULT]: true, status, body };
228
+ }
229
+
230
+ function isApiResult(value: unknown): value is ApiResult {
231
+ return typeof value === 'object' && value !== null && API_RESULT in value;
232
+ }
233
+ ```
234
+
235
+ In `buildRoutes`, the response step becomes:
236
+
237
+ ```ts
238
+ const val = await route.handler.apply(instance, [ctx, body]);
239
+ if (isApiResult(val)) {
240
+ if (val.body !== undefined) {
241
+ if (validateResponses && route.meta?.responses)
242
+ validateResponseBody(route.meta.responses, val.status, val.body, schemaComponents, validateResponses);
243
+ sendJson(res, val.body);
244
+ res.statusCode = val.status; // set before send, or sendJson(res, val.body, val.status)
245
+ } else {
246
+ res.status(val.status).end();
247
+ }
248
+ } else if (val !== undefined && val !== null && val !== false && val !== 0 && val !== '') {
249
+ // unchanged: existing truthy → 200 path
250
+ } else {
251
+ res.status(201).end(); // unchanged default; see §7 for the 201→204 question
252
+ }
253
+ ```
254
+
255
+ (`sendJson` needs the status set before `res.send()` — the sketch above
256
+ needs the actual ordering fixed at implementation time; shown here to
257
+ illustrate the branch, not as final code.)
258
+
259
+ Usage:
260
+
261
+ ```ts
262
+ POST: {
263
+ '/items': (ctx, body) => withStatus(201, createItem(body)), // 201 Created + body
264
+ },
265
+ DELETE: {
266
+ '/items/:id': (ctx) => { deleteItem(ctx.params.id); return withStatus(204); },
267
+ },
268
+ GET: {
269
+ '/items': () => listItems(), // unchanged: truthy → 200
270
+ },
271
+ ```
272
+
273
+ This is purely additive — existing handlers returning plain values are
274
+ untouched. Response-schema validation (`validateResponseBody`), which today
275
+ hardcodes status `200`, is generalized to validate against whichever status
276
+ was actually sent.
277
+
278
+ ---
279
+
280
+ ## 6. P3 — `errorTranslator()`: one mapping, two seams
281
+
282
+ ```ts
283
+ /**
284
+ * Build a single err → ApiError mapping usable both as a `service.onError`
285
+ * hook and as a `router.error()` middleware, so an application-specific
286
+ * error hierarchy is translated exactly once regardless of which seam a
287
+ * given route falls through.
288
+ */
289
+ export function errorTranslator(
290
+ translate: (err: unknown) => ApiError | undefined,
291
+ ): {
292
+ /** Plug into `ServiceDefinition.onError`. */
293
+ onError: (err: unknown) => ApiError | undefined;
294
+ /** Plug into `router.error()`, for routes outside this `apiBuilder` call. */
295
+ middleware: ErrorMiddleware;
296
+ } {
297
+ return {
298
+ onError: translate,
299
+ middleware: (err, _req, res, next) => {
300
+ const apiErr = translate(err);
301
+ if (!apiErr) return next(err); // not ours — forward, let other handlers/bubbling decide
302
+ const status = apiErr.status ?? 500;
303
+ if (apiErr.data !== undefined) {
304
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
305
+ res.status(status).send(JSON.stringify(apiErr.data));
306
+ } else {
307
+ res.status(status).send(apiErr.message ?? 'Internal error');
308
+ }
309
+ },
310
+ };
311
+ }
312
+ ```
313
+
314
+ Usage — the `AppError → ApiError` mapping is written once:
315
+
316
+ ```ts
317
+ const errors = errorTranslator(err =>
318
+ err instanceof AppError ? { status: err.status, data: { code: err.code, message: err.message } } : undefined);
319
+
320
+ const api = apiBuilder({
321
+ onError: err => errors.onError(err),
322
+ // ...
323
+ });
324
+
325
+ app.use('/api', api);
326
+ app.error(errors.middleware); // catches AppError thrown by routes outside apiBuilder
327
+ ```
328
+
329
+ `.code` is preserved end to end (inside `apiErr.data`), so structured clients
330
+ keep working without the project giving up its own error hierarchy.
331
+
332
+ ---
333
+
334
+ ## 7. Open question: should the falsy-return default move from `201` to `204`?
335
+
336
+ Independent of `withStatus()`, the existing default for a falsy return is
337
+ `res.status(201).end()` with no body — which doesn't match `201 Created`'s
338
+ conventional meaning (a representation of the created resource, often with
339
+ `Location`). `204 No Content` is the status that means "succeeded, nothing
340
+ to send." `withStatus()` already lets any handler opt into the *correct*
341
+ status today; this section is only about whether the **default** should
342
+ change.
343
+
344
+ This is a behavioural break for any existing handler relying on a bare
345
+ falsy return meaning `201` (status code visible to clients changes from 201
346
+ to 204). Given `apiBuilder` has shipped two minor versions with `201` as the
347
+ documented default, this is listed as an open question for a deliberately
348
+ scheduled breaking change rather than bundled into this round.
349
+
350
+ ---
351
+
352
+ ## 8. Documentation fix (no code change)
353
+
354
+ Add a "Stateless services" example to `docs/api-builder.md`, alongside the
355
+ existing `data()`/`this`/`methods` one, showing a service with none of
356
+ `scope`/`data`/`methods` — just closures over injected dependencies and
357
+ arrow-function handlers — to make explicit what `api-builder-v2-design.md`
358
+ §9 already demonstrates in passing. This addresses PP-stateful-model from
359
+ §1 without touching `src/`.
360
+
361
+ ---
362
+
363
+ ## 9. Breaking-changes summary
364
+
365
+ | Change | Impact |
366
+ |---|---|
367
+ | `req.res` link on every request | additive |
368
+ | `AuthBinding.check` gains a third `req` parameter | additive (existing two-arg checks keep compiling) |
369
+ | `AuthBinding.resolveUser` | additive; default preserves current `req.user` behaviour |
370
+ | `withStatus()` / `ApiResult` | additive; existing return-value handlers unchanged |
371
+ | `validateResponseBody` validates against the actual sent status, not a hardcoded `200` | additive (only changes behaviour for handlers using `withStatus()`, which are new) |
372
+ | `errorTranslator()` | additive, new export |
373
+ | Falsy-return default `201` → `204` | **not** part of this round — open question, §7 |
374
+
375
+ ## 10. Implementation order
376
+
377
+ 1. **`req.res` link** — trivial, isolated (`http-objects.ts`). Test: a guard
378
+ reading `req.res` inside a request and setting a header that the test
379
+ asserts on the actual HTTP response.
380
+ 2. **`AuthBinding.check(req)` + `resolveUser`** — `apis.ts` pipeline change.
381
+ Tests: `check` reading `req`, `resolveUser` overriding `ctx.user`
382
+ population, default behaviour unchanged when neither is supplied.
383
+ 3. **`withStatus()`** — new export + `buildRoutes` response branch. Tests:
384
+ `201` with body, `204` without body, existing truthy/falsy paths
385
+ unchanged, response-schema validation against the dynamic status.
386
+ 4. **`errorTranslator()`** — new export. Tests: `onError` usage, `middleware`
387
+ usage standalone on a plain router, `.code`/custom fields preserved,
388
+ forwarding (`next(err)`) when `translate` returns `undefined`.
389
+ 5. **Docs** — stateless-services example in `api-builder.md`; new sections
390
+ for `req.res`, `resolveUser`, `withStatus()`, `errorTranslator()`;
391
+ `docs/errors.md` gains a recipe showing `errorTranslator()` bridging
392
+ `apiBuilder` and `router.error()`.
393
+
394
+ Per the project's development checklist: full JSDoc on every new export,
395
+ re-export from `src/index.ts`, tests in `tests/apis.test.ts` /
396
+ `tests/api-guards.test.ts` per step, README and `docs/api-builder.md` /
397
+ `docs/errors.md` updates after implementation.