expediate 1.0.4 → 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 (69) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/CONTRIBUTING.md +150 -0
  3. package/LICENSE +16 -16
  4. package/README.md +330 -444
  5. package/dist/apis.d.ts +504 -27
  6. package/dist/apis.d.ts.map +1 -1
  7. package/dist/apis.js +618 -107
  8. package/dist/apis.js.map +1 -1
  9. package/dist/cjs/index.js +4066 -0
  10. package/dist/cjs/package.json +1 -0
  11. package/dist/git.d.ts +72 -9
  12. package/dist/git.d.ts.map +1 -1
  13. package/dist/git.js +129 -74
  14. package/dist/git.js.map +1 -1
  15. package/dist/http-objects.d.ts +26 -0
  16. package/dist/http-objects.d.ts.map +1 -0
  17. package/dist/http-objects.js +588 -0
  18. package/dist/http-objects.js.map +1 -0
  19. package/dist/index.d.ts +18 -13
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +15 -24
  22. package/dist/index.js.map +1 -1
  23. package/dist/jwt-auth.d.ts +158 -57
  24. package/dist/jwt-auth.d.ts.map +1 -1
  25. package/dist/jwt-auth.js +447 -207
  26. package/dist/jwt-auth.js.map +1 -1
  27. package/dist/middleware.d.ts +476 -0
  28. package/dist/middleware.d.ts.map +1 -0
  29. package/dist/middleware.js +647 -0
  30. package/dist/middleware.js.map +1 -0
  31. package/dist/mimetypes.json +882 -1
  32. package/dist/misc.d.ts +268 -25
  33. package/dist/misc.d.ts.map +1 -1
  34. package/dist/misc.js +449 -168
  35. package/dist/misc.js.map +1 -1
  36. package/dist/openapi.d.ts +433 -0
  37. package/dist/openapi.d.ts.map +1 -0
  38. package/dist/openapi.js +624 -0
  39. package/dist/openapi.js.map +1 -0
  40. package/dist/router-types.d.ts +760 -0
  41. package/dist/router-types.d.ts.map +1 -0
  42. package/dist/router-types.js +23 -0
  43. package/dist/router-types.js.map +1 -0
  44. package/dist/router.d.ts +37 -201
  45. package/dist/router.d.ts.map +1 -1
  46. package/dist/router.js +502 -244
  47. package/dist/router.js.map +1 -1
  48. package/dist/static.d.ts +3 -3
  49. package/dist/static.d.ts.map +1 -1
  50. package/dist/static.js +164 -105
  51. package/dist/static.js.map +1 -1
  52. package/docs/THREAT_MODEL.md +52 -0
  53. package/docs/api-builder-v2-design.md +644 -0
  54. package/docs/api-builder-v3-design.md +397 -0
  55. package/docs/api-builder.md +454 -0
  56. package/docs/benchmark.md +27 -0
  57. package/docs/body-parsing.md +223 -0
  58. package/docs/errors.md +359 -0
  59. package/docs/expediate.png +0 -0
  60. package/docs/git.md +139 -0
  61. package/docs/jwt-auth.md +251 -0
  62. package/docs/logo.svg +12 -0
  63. package/docs/middleware.md +264 -0
  64. package/docs/openapi.md +180 -0
  65. package/docs/router.md +356 -0
  66. package/docs/static.md +128 -0
  67. package/docs/wiki.json +123 -0
  68. package/package.json +47 -8
  69. package/.npmignore +0 -16
@@ -0,0 +1,644 @@
1
+ # API Builder v2 — Composition, Guards, and Auth Binding
2
+
3
+ **Status:** Draft / design discussion
4
+ **Date:** 2026-06-10
5
+ **Scope:** `src/apis.ts`, `src/openapi.ts`, touch points with `src/jwt-auth.ts`
6
+ **Compatibility:** breaking changes allowed (target: v2.0)
7
+
8
+ ---
9
+
10
+ ## 1. Context
11
+
12
+ A real consumer of `apiBuilder` (the DevLab project) was reviewed as a field
13
+ study. Its single `api-service.ts` is **3,480 lines** and contains:
14
+
15
+ - **~92 routes** in one `ServiceDefinition` across `GET` / `POST` / `PUT` / `DELETE`
16
+ - **13 injected services** passed as positional factory arguments
17
+ - ~70 routes sharing the `/p/:proj` prefix
18
+ - hand-rolled authorization helpers (`requireAdmin`, `requirePerm`) called
19
+ manually at the top of nearly every handler
20
+ - manual body validation duplicating the JSON Schemas already declared in
21
+ `describe()` metadata
22
+ - the `scope` / `data` / `methods` service-instance model entirely unused —
23
+ every dependency is a closure capture
24
+
25
+ None of this is bad client code. It is the *only* shape the current API
26
+ allows: `ServiceDefinition` is the sole unit of grouping, and it owns the
27
+ whole API. When the API grows, the file grows with it. The framework offers
28
+ no seam along which to cut.
29
+
30
+ ## 2. Pain points (evidence)
31
+
32
+ ### PP1 — No composition unit
33
+
34
+ `apiBuilder(service)` accepts exactly one definition. There is no supported
35
+ way to define the Wiki routes in `wiki.controller.ts` and the Issues routes
36
+ in `issues.controller.ts` and merge them into one router with one OpenAPI
37
+ document. Mounting several `apiBuilder()` routers under `app.use()` *almost*
38
+ works, but each router produces its own spec, owns its own 503 guard and
39
+ instance cache, and route specificity sorting is no longer global.
40
+
41
+ ### PP2 — Prefix repetition
42
+
43
+ `'/p/:proj/...'` is typed ~70 times. Any rename of the URL scheme is a
44
+ 70-site edit.
45
+
46
+ ### PP3 — Repeated cross-cutting preamble
47
+
48
+ The same four lines open almost every handler. The Wiki group repeats this
49
+ block six times, Snippets four times, Issues/MRs use a variant ~15 times:
50
+
51
+ ```ts
52
+ '/p/:proj/wiki/pages/:slug': describe(async function (ctx: ApiContext): Promise<WikiPage> {
53
+ const projectName = ctx.query.route.proj;
54
+ await requirePerm(ctx, projectName, 'wiki.read')
55
+ const proj = await projectService.openProject(projectName);
56
+ if (!proj.wikiEnabled) throw HttpErr.NotFound('Wiki is not enabled for this project');
57
+ return wikiService.readPage(projectName, ctx.query.route.slug);
58
+ }, { ... }),
59
+ ```
60
+
61
+ Authorization, resource loading, and feature-flag checks are cross-cutting
62
+ concerns, but the API Builder gives handlers no "before" hook — the only
63
+ middleware seam is the raw router level, *outside* the `ctx` world.
64
+
65
+ ### PP4 — Permissions are disconnected from the JWT plugin
66
+
67
+ `createJwtPlugin` ships `requireRole` / `requirePermission` middleware, but
68
+ they are unusable inside `apiBuilder` handlers: they live in the
69
+ `(req, res, next)` world while service methods live in the `(ctx, body)`
70
+ world. The client reimplements the entire authorization layer
71
+ (`requireAdmin`, `requirePerm`, `authService`) and types `ctx.user` as
72
+ `any`, with `(ctx.user as any).ipAddress` casts scattered around.
73
+
74
+ ### PP5 — Declared schemas are write-only
75
+
76
+ Every route carries a `requestBody` JSON Schema for the OpenAPI spec — and
77
+ then validates the body *again* by hand (`applySettingsBody` is 150 lines of
78
+ manual field checks; milestone creation re-implements `pattern` and
79
+ `required`). The framework already holds machine-readable validation rules
80
+ and never executes them.
81
+
82
+ ### PP6 — Context verbosity
83
+
84
+ `ctx.query.route.proj` is the most-typed expression in the file. The
85
+ namespacing is correct (route vs URL params must not collide) but the common
86
+ case deserves a shorthand.
87
+
88
+ ## 3. Goals and non-goals
89
+
90
+ **Goals**
91
+
92
+ 1. Let a client split one API into per-domain files that merge into a single
93
+ router and a single OpenAPI document, with global route sorting and
94
+ build-time conflict detection.
95
+ 2. Provide a declarative authorization seam inside the `ctx` world, bridged
96
+ to `jwt-auth` out of the box and overridable for resource-scoped checks.
97
+ 3. Execute the JSON Schemas the client already writes.
98
+ 4. Keep zero runtime dependencies and the data-first, no-decorator style.
99
+
100
+ **Non-goals**
101
+
102
+ - A dependency-injection container. Closure capture works well; the missing
103
+ piece is file-level splitting, not DI.
104
+ - TypeScript decorators (`@Get('/path')`). They impose compiler options on
105
+ consumers and break the "plain objects" philosophy of the framework.
106
+ - App-specific conveniences (pagination parsing, dry-run protocol, audit-log
107
+ hooks). These belong in client helper functions; the framework only needs
108
+ to make such helpers easy to apply (guards, see P2).
109
+
110
+ ---
111
+
112
+ ## 4. P1 — Controllers and composition
113
+
114
+ ### New types
115
+
116
+ ```ts
117
+ /** A group of routes sharing a path prefix, tags, and guards. */
118
+ export interface ControllerDefinition<TInstance extends ServiceInstance = ServiceInstance> {
119
+ /** Path prefix prepended to every route in this controller (may contain params). */
120
+ prefix?: string;
121
+ /** Default OpenAPI tags applied to routes that do not declare their own. */
122
+ tags?: string[];
123
+ /** Guards run before every handler of this controller (see P2). */
124
+ guards?: Guard[];
125
+ /** Default permission requirement for every route (see P3). Route-level meta overrides. */
126
+ permission?: string | string[];
127
+
128
+ GET?: RouteMap<TInstance>;
129
+ POST?: RouteMap<TInstance>;
130
+ PUT?: RouteMap<TInstance>;
131
+ DELETE?: RouteMap<TInstance>;
132
+ PATCH?: RouteMap<TInstance>;
133
+ }
134
+
135
+ /** Identity helper for type inference and discoverability. */
136
+ export function defineController<TInstance extends ServiceInstance = ServiceInstance>(
137
+ c: ControllerDefinition<TInstance>,
138
+ ): ControllerDefinition<TInstance> { return c; }
139
+ ```
140
+
141
+ ### Extended `ServiceDefinition`
142
+
143
+ ```ts
144
+ export interface ServiceDefinition<TInstance extends ServiceInstance = ServiceInstance> {
145
+ scope?: (req: RouterRequest) => string | null;
146
+ data?: (key: string | null) => Partial<TInstance>;
147
+ setup?: (this: TInstance) => void | Promise<void>;
148
+ methods?: ServiceMethods<TInstance>;
149
+
150
+ /** NEW — sub-controllers merged into this API. */
151
+ controllers?: ControllerDefinition<TInstance>[];
152
+ /** NEW — guards run before every handler of the whole API (see P2). */
153
+ guards?: Guard[];
154
+ /** NEW — authentication/authorization binding (see P3). */
155
+ auth?: AuthBinding;
156
+ /** NEW — runtime validation of declared request schemas (see P4). */
157
+ validate?: boolean | ApiBuilderOptions;
158
+ /** NEW — schema components, shared by validation and spec generation. */
159
+ schemas?: Record<string, JsonSchema>;
160
+
161
+ // Root-level route maps remain valid — they form an implicit controller
162
+ // with no prefix. Existing v1 definitions therefore still compile.
163
+ GET?: RouteMap<TInstance>; POST?: ...; PUT?: ...; DELETE?: ...; PATCH?: ...;
164
+ }
165
+ ```
166
+
167
+ The instance model (`scope` / `data` / `setup` / `methods`) stays at the top
168
+ level: one instance lifecycle for the whole API, shared by all controllers.
169
+ Controllers are *route organisation*, not isolation boundaries — handlers in
170
+ every controller run with `this` bound to the same service instance.
171
+
172
+ ### Merge algorithm
173
+
174
+ At build time, `apiBuilder`:
175
+
176
+ 1. Normalises the root route maps into an anonymous controller
177
+ (`prefix: ''`).
178
+ 2. For each controller and verb, rewrites each path to
179
+ `joinPath(prefix, path)` (normalising duplicate slashes; `'/'` route +
180
+ prefix `/p/:proj/wiki` → `/p/:proj/wiki`).
181
+ 3. Concatenates all routes of all controllers per verb, then applies the
182
+ existing specificity sort **globally**. The current score
183
+ (`segments * 100 − params * 10`) already accounts for prefix parameters
184
+ since scoring happens on the joined path.
185
+ 4. **Throws at build time** on a duplicate `(verb, joined path)` pair:
186
+
187
+ ```
188
+ Error: apiBuilder: duplicate route GET /p/:proj/settings
189
+ declared by controllers 'Settings' and 'Projects'
190
+ ```
191
+
192
+ Today a duplicate silently shadows; with multi-file composition, loud
193
+ failure becomes essential.
194
+ 5. Records per-route provenance (controller tags, guards, permission) in a
195
+ merged metadata table consumed by both the request pipeline and
196
+ `openApiSpec()`.
197
+
198
+ `openApiSpec()` operates on the merged table, so `api.spec()` /
199
+ `api.specHandler()` keep producing **one** document. Controller `tags` fill
200
+ in `OperationMeta.tags` when a route declares none.
201
+
202
+ ---
203
+
204
+ ## 5. P2 — Guards and `ctx.state`
205
+
206
+ A *guard* is a pre-handler hook living in the `ctx` world:
207
+
208
+ ```ts
209
+ /**
210
+ * Runs before the route handler. May:
211
+ * - throw / reject an ApiError → translated to an HTTP error response;
212
+ * - return an object → shallow-merged into `ctx.state`;
213
+ * - return void → pure check.
214
+ */
215
+ export type Guard = (
216
+ ctx: ApiContext,
217
+ req: RouterRequest,
218
+ ) => void | Record<string, unknown> | Promise<void | Record<string, unknown>>;
219
+ ```
220
+
221
+ Guards attach at three levels and run outermost-first:
222
+
223
+ ```
224
+ auth.authenticate → auth.check → api.guards → controller.guards → route meta.guards → handler
225
+ ```
226
+
227
+ Per-route guards ride on `OperationMeta` (the natural per-route metadata
228
+ slot we already have; spec generation simply ignores the field):
229
+
230
+ ```ts
231
+ describe(handler, { summary: '...', guards: [onlyOwner] })
232
+ ```
233
+
234
+ `ApiContext` gains a `state` bag for guard-produced values:
235
+
236
+ ```ts
237
+ export interface ApiContext<TUser = unknown, TState = Record<string, unknown>> {
238
+ query: { route: Record<string, string>; url: Record<string, string | string[]> };
239
+ /** Shorthand for `query.route`. */
240
+ params: Record<string, string>;
241
+ path: string;
242
+ user?: TUser;
243
+ /** Values produced by guards (loaded resources, resolved roles, …). */
244
+ state: TState;
245
+ }
246
+ ```
247
+
248
+ This is the feature that erases PP3: "check the permission, load the
249
+ project, check the feature flag" becomes three small reusable guards, and
250
+ handlers shrink to their actual business logic. Because a guard can *load
251
+ and share* a resource via `ctx.state`, it also removes the duplicated
252
+ `openProject` call that today runs once in `requirePerm` and once in the
253
+ handler.
254
+
255
+ Failure mode is the existing `ApiError` contract — guards need no new error
256
+ channel.
257
+
258
+ ---
259
+
260
+ ## 6. P3 — Auth binding: connecting `jwt-auth` to the API Builder
261
+
262
+ ### The binding
263
+
264
+ ```ts
265
+ export interface AuthBinding<TUser = unknown> {
266
+ /**
267
+ * Router middleware run before any guard or handler — typically
268
+ * `jwtPlugin.authenticate`. Registered by apiBuilder on its internal
269
+ * router, so the client no longer wires it per-mount.
270
+ */
271
+ authenticate?: Middleware;
272
+
273
+ /**
274
+ * Enforce a permission requirement for the current request.
275
+ * Default implementation: require `ctx.user` and check
276
+ * `ctx.user.permissions` contains all required entries — i.e. the exact
277
+ * semantics of `jwtPlugin.requirePermission`, but in the ctx world.
278
+ * Override for resource-scoped models (per-project roles, ownership, …).
279
+ */
280
+ check?: (ctx: ApiContext<TUser>, required: string[]) => void | Promise<void>;
281
+
282
+ /**
283
+ * OpenAPI security scheme emitted into `components.securitySchemes`.
284
+ * Default: `{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }`.
285
+ */
286
+ scheme?: Record<string, unknown>;
287
+ }
288
+ ```
289
+
290
+ ### Declarative requirement
291
+
292
+ `OperationMeta` and `ControllerDefinition` gain `permission`:
293
+
294
+ ```ts
295
+ describe(handler, { summary: '...', permission: 'wiki.read' })
296
+ // or for a whole controller:
297
+ defineController({ prefix: '/admin', permission: 'manage_users', ... })
298
+ ```
299
+
300
+ When a route (or its controller) declares `permission`, the pipeline runs
301
+ `auth.check(ctx, required)` before the guards. Routes without a
302
+ `permission` stay public — authorization is opt-in per route, matching the
303
+ `authenticate`/`authorize` split already used by the JWT plugin.
304
+
305
+ ### Out-of-the-box JWT integration
306
+
307
+ ```ts
308
+ const jwt = createJwtPlugin({ accessTokenSecret: SECRET });
309
+
310
+ const api = apiBuilder({
311
+ auth: { authenticate: jwt.authenticate }, // default check() reads ctx.user.permissions
312
+ controllers: [ ... ],
313
+ });
314
+ ```
315
+
316
+ For DevLab's resource-scoped model the client overrides `check` **once**,
317
+ replacing the per-handler `requirePerm` calls:
318
+
319
+ ```ts
320
+ auth: {
321
+ authenticate: jwt.authenticate,
322
+ check: async (ctx, required) => {
323
+ const proj = await projectService.openProject(ctx.params.proj);
324
+ for (const p of required)
325
+ authService.requirePermission(ctx.user?.sub ?? null, proj, p as Permission);
326
+ ctx.state.proj = proj; // share the loaded project with guards/handler
327
+ },
328
+ },
329
+ ```
330
+
331
+ ### OpenAPI output
332
+
333
+ Routes carrying a `permission` automatically receive
334
+ `security: [{ bearerAuth: [] }]` and a vendor extension
335
+ `x-required-permissions: [...]` in the generated document, and
336
+ `components.securitySchemes.bearerAuth` is emitted once. Today the spec
337
+ says nothing about auth at all.
338
+
339
+ ### Typed user
340
+
341
+ `ApiContext<TUser>` replaces `user?: any`. `jwt-auth` exports
342
+ `TokenPayload`, so DevLab writes `ApiContext<TokenPayload>` and the
343
+ `(ctx.user as any)` casts disappear.
344
+
345
+ ---
346
+
347
+ ## 7. P4 — Request validation from declared schemas
348
+
349
+ ```ts
350
+ export interface ApiBuilderOptions {
351
+ /** Validate request bodies against `meta.requestBody` schemas (400 on failure). Default true. */
352
+ validateRequests?: boolean;
353
+ /** Validate handler returns against `meta.responses['200']`: true → 500, 'warn' → log only. Default false. */
354
+ validateResponses?: boolean | 'warn';
355
+ }
356
+ ```
357
+
358
+ When `validate` is enabled and a route's `OperationMeta.requestBody`
359
+ declares a schema, the body is checked before the handler runs. Failures
360
+ produce `400` with a body mirroring the client's existing `fieldErrors`
361
+ convention:
362
+
363
+ ```json
364
+ { "message": "Request body validation failed",
365
+ "fieldErrors": { "name": "does not match pattern ^[a-z0-9][a-z0-9.\\-]*$" } }
366
+ ```
367
+
368
+ Implementation: a small internal validator covering the pragmatic subset of
369
+ the already-exported `JsonSchema` type — `type`, `required`, `properties`,
370
+ `items`, `enum`, `pattern`, `minLength`/`maxLength`, `minimum`/`maximum`,
371
+ `additionalProperties`, `allOf`/`anyOf`/`oneOf`, and `$ref` resolved against
372
+ `ServiceDefinition.schemas`. Roughly 150 lines, zero dependencies, shared
373
+ with nothing else (no ajv-style compilation needed at these traffic levels).
374
+
375
+ `schemas` moves the component map from `SpecOptions` (doc-generation time)
376
+ to the service definition (build time), so one declaration feeds both the
377
+ spec and the validator; `openApiSpec()` reads it from the service when
378
+ present.
379
+
380
+ This deletes most of the client's hand-rolled checks: the milestone
381
+ `NAME_RE` test, the `applySettingsBody` type/enum/length checks, the
382
+ "must be a string array" guards — all already expressible in the schemas
383
+ the client writes anyway.
384
+
385
+ ---
386
+
387
+ ## 8. P5 — Context ergonomics
388
+
389
+ Small, mechanical additions to `ApiContext` (all shown in §5):
390
+
391
+ - `ctx.params` — alias of `ctx.query.route`. The dominant access pattern
392
+ deserves the short name; the namespaced form remains for collisions.
393
+ - `ctx.state` — guard-produced values (see P2).
394
+ - `ApiContext<TUser, TState>` generics — typed `user` and `state`.
395
+
396
+ Rejected: built-in pagination parsing (`start`/`count` conventions are
397
+ app-specific), a response-shaping hook (the `Edition`/dry-run protocol is
398
+ DevLab domain logic), and `ctx.req`/`ctx.res` escape hatches (handlers that
399
+ need the raw objects should be plain router middleware).
400
+
401
+ ---
402
+
403
+ ## 9. Worked example — refactoring the DevLab monolith
404
+
405
+ Target layout (one file per domain, ~150–400 lines each):
406
+
407
+ ```
408
+ api/
409
+ index.ts — composition root (apiBuilder call, auth binding)
410
+ guards.ts — featureEnabled(), loadProject(), requireAdmin
411
+ auth.controller.ts — /auth/*
412
+ projects.controller.ts — /projects, /p/:proj, /p/:proj/settings
413
+ git.controller.ts — commits, refs, files, git-update hooks
414
+ wiki.controller.ts — /p/:proj/wiki/*
415
+ snippets.controller.ts — /p/:proj/snippets/*
416
+ issues.controller.ts — issues, comments, reactions
417
+ merge-requests.controller.ts
418
+ pipelines.controller.ts — pipelines + CI runner helpers
419
+ releases.controller.ts — releases, milestones promote
420
+ admin.controller.ts — /admin/namespaces, /users
421
+ notifications.controller.ts
422
+ ```
423
+
424
+ ### `guards.ts`
425
+
426
+ ```ts
427
+ import type { Guard } from 'expediate';
428
+ import { HttpErr } from './utils';
429
+
430
+ /** Require a project feature flag; expects ctx.state.proj loaded by auth.check(). */
431
+ export const featureEnabled = (flag: 'wikiEnabled' | 'snippetsEnabled'): Guard =>
432
+ (ctx) => {
433
+ const proj = ctx.state.proj as DbProject;
434
+ if (!proj?.[flag]) throw HttpErr.NotFound('Feature is not enabled for this project');
435
+ };
436
+
437
+ /** Require a system administrator. */
438
+ export const requireAdmin: Guard = (ctx) => {
439
+ const username = ctx.user?.sub;
440
+ if (!username) throw HttpErr.Unauthorized('Authentication required');
441
+ const user = userService.listUsers().find(u => u.username === username);
442
+ if (!user?.isAdmin) throw HttpErr.Forbidden('Admin access required');
443
+ return { username }; // → ctx.state.username
444
+ };
445
+ ```
446
+
447
+ ### `wiki.controller.ts` — before vs after
448
+
449
+ Before (current code, **one of six** near-identical handlers):
450
+
451
+ ```ts
452
+ '/p/:proj/wiki/pages/:slug': describe(async function (ctx: ApiContext): Promise<WikiPage> {
453
+ const projectName = ctx.query.route.proj;
454
+ await requirePerm(ctx, projectName, 'wiki.read')
455
+ const proj = await projectService.openProject(projectName);
456
+ if (!proj.wikiEnabled) throw HttpErr.NotFound('Wiki is not enabled for this project');
457
+ return wikiService.readPage(projectName, ctx.query.route.slug);
458
+ }, {
459
+ summary: 'Read a wiki page',
460
+ operationId: 'getWikiPage',
461
+ tags: ['Wiki'],
462
+ responses: ok(ref('WikiPage')),
463
+ }),
464
+ ```
465
+
466
+ After (whole controller):
467
+
468
+ ```ts
469
+ import { defineController, describe } from 'expediate';
470
+ import { featureEnabled } from './guards';
471
+
472
+ export const wikiController = (wikiService: WikiService) => defineController({
473
+ prefix: '/p/:proj/wiki',
474
+ tags: ['Wiki'],
475
+ permission: 'wiki.read', // auth.check() runs for every route
476
+ guards: [featureEnabled('wikiEnabled')], // proj already loaded by auth.check()
477
+
478
+ GET: {
479
+ '/tree': describe(
480
+ (ctx) => wikiService.listPages(ctx.params.proj),
481
+ { summary: 'List all wiki pages', operationId: 'getWikiTree',
482
+ responses: ok(arrayOf('WikiTreeItem')) }),
483
+
484
+ '/pages/:slug': describe(
485
+ (ctx) => wikiService.readPage(ctx.params.proj, ctx.params.slug),
486
+ { summary: 'Read a wiki page', operationId: 'getWikiPage',
487
+ responses: ok(ref('WikiPage')) }),
488
+
489
+ '/pages/:slug/revisions': describe(
490
+ (ctx) => wikiService.pageRevisions(ctx.params.proj, ctx.params.slug),
491
+ { summary: 'List revision history', operationId: 'getWikiPageRevisions',
492
+ responses: ok(arrayOf('WtRevision')) }),
493
+
494
+ '/pages/:slug/revisions/:version': describe(
495
+ (ctx) => wikiService.pageAtRevision(ctx.params.proj, ctx.params.slug, ctx.params.version),
496
+ { summary: 'Read a page at a revision', operationId: 'getWikiPageAtRevision',
497
+ responses: ok({ type: 'string' }) }),
498
+ },
499
+
500
+ POST: {
501
+ '/search': describe(
502
+ (ctx, body: WikiSearchRequest) =>
503
+ wikiService.searchPages(ctx.params.proj, body.query ?? '', body.caseSensitive ?? false),
504
+ { summary: 'Full-text wiki search', operationId: 'searchWiki',
505
+ permission: 'wiki.read',
506
+ requestBody: jsonBody(ref('WikiSearchRequest')),
507
+ responses: ok(arrayOf('WikiSearchResult')) }),
508
+ },
509
+
510
+ PUT: {
511
+ '/pages/:slug': describe(
512
+ (ctx, body) => wikiService.writePage(ctx.params.proj, ctx.params.slug, body, ctx.user!.sub),
513
+ { summary: 'Create or update a wiki page', operationId: 'putWikiPage',
514
+ permission: 'wiki.write', // route-level override
515
+ requestBody: jsonBody(ref('WikiPageUpdate')),
516
+ responses: ok(ref('WikiPage')) }),
517
+ },
518
+ });
519
+ ```
520
+
521
+ Each handler is now one expression. The six-line preamble exists exactly
522
+ once — in the auth binding and one guard.
523
+
524
+ ### `issues.controller.ts` (excerpt)
525
+
526
+ The mutation routes keep their domain logic (edit service, notifications,
527
+ audit logs are app concerns) but lose the auth/loading preamble:
528
+
529
+ ```ts
530
+ export const issuesController = (deps: Deps) => defineController({
531
+ prefix: '/p/:proj/issues',
532
+ tags: ['Issues'],
533
+
534
+ POST: {
535
+ '/:uid/comments': describe(async (ctx, body) => {
536
+ const { proj } = ctx.state; // loaded by auth.check()
537
+ const user = await deps.userService.readUser(ctx.user!.sub);
538
+ const edit = await itemEdition<DataIssue>(ctx.params.proj, 'issue', ctx.params.uid,
539
+ !!body.dryRun, (item, ec) => deps.editService.editAddComment(item, body, user, nowISO(), ec));
540
+ if (edit.success && edit.data)
541
+ deps.notificationService.onComment(ctx.params.proj, 'issue',
542
+ edit.data.uid, edit.data.title, user, edit.data.participants);
543
+ return { ...edit, data: edit.data ? deps.mapper.mapDtoIssueFull(edit.data, proj, []) : null };
544
+ }, {
545
+ summary: 'Add a comment to an issue', operationId: 'commentIssue',
546
+ permission: 'issue.comment',
547
+ requestBody: jsonBody(ref('CommentUpdate')),
548
+ responses: ok(editionOf('DtoIssueFull')),
549
+ }),
550
+ },
551
+ });
552
+ ```
553
+
554
+ ### `index.ts` — composition root
555
+
556
+ ```ts
557
+ export function useApiService(deps: Deps, jwt: JwtPlugin): ApiRouter {
558
+ return apiBuilder({
559
+ auth: {
560
+ authenticate: jwt.authenticate,
561
+ check: async (ctx, required) => {
562
+ const proj = await deps.projectService.openProject(ctx.params.proj);
563
+ for (const p of required)
564
+ deps.authService.requirePermission(ctx.user?.sub ?? null, proj, p as Permission);
565
+ ctx.state.proj = proj;
566
+ },
567
+ },
568
+ validate: true,
569
+ schemas: dtoSchemas, // shared by spec + validator
570
+
571
+ controllers: [
572
+ authController(deps),
573
+ projectsController(deps),
574
+ gitController(deps),
575
+ wikiController(deps.wikiService),
576
+ snippetsController(deps),
577
+ issuesController(deps),
578
+ mergeRequestsController(deps),
579
+ pipelinesController(deps),
580
+ releasesController(deps),
581
+ adminController(deps),
582
+ notificationsController(deps),
583
+ ],
584
+ });
585
+ }
586
+ ```
587
+
588
+ Estimated effect on the monolith: 3,480 lines → ~12 files of 150–400 lines;
589
+ roughly 500 lines of repeated preamble and manual validation deleted
590
+ outright.
591
+
592
+ ---
593
+
594
+ ## 10. Breaking-changes summary
595
+
596
+ Although breaking changes are allowed, the design lands as a superset:
597
+
598
+ | Change | Impact |
599
+ |---|---|
600
+ | `ApiContext` gains `params`, `state`, generics | additive; `query.route` untouched |
601
+ | `ServiceDefinition` gains `controllers`, `guards`, `auth`, `validate`, `schemas` | additive |
602
+ | `OperationMeta` gains `guards`, `permission` | additive |
603
+ | Duplicate `(verb, path)` now **throws** at build time | behavioural break (was silent shadowing) — intended |
604
+ | `SpecOptions.components.schemas` superseded by `ServiceDefinition.schemas` | soft break; spec options form kept as fallback |
605
+ | Default `ApiContext.user` type `unknown` instead of `any` | compile-time break for sloppy accesses — intended |
606
+
607
+ The v1 single-definition style keeps working; v2 is the same API with seams.
608
+
609
+ ## 11. Implementation order
610
+
611
+ 1. **`ctx.params` + generics** — trivial, isolated (`apis.ts`).
612
+ 2. **Controller merge + conflict detection** — `apis.ts` route collection
613
+ refactor; `openapi.ts` reads the merged table. Tests: prefix joining,
614
+ global sort across controllers, duplicate-route throw, merged spec tags.
615
+ 3. **Guards + `ctx.state`** — pipeline change in `buildRoutes`. Tests:
616
+ ordering (api → controller → route), state merging, ApiError from guard.
617
+ 4. **Auth binding** — default `check` against `ctx.user.permissions`
618
+ (jwt-auth semantics), `authenticate` auto-registration, OpenAPI
619
+ `security` emission. Tests with `createJwtPlugin` end-to-end.
620
+ 5. **Validation** — internal JSON-Schema subset validator + `$ref`
621
+ resolution. Tests: each keyword, `fieldErrors` shape, opt-out.
622
+
623
+ Per the development checklist: full JSDoc on all new exports, re-export
624
+ from `src/index.ts`, tests per step in `tests/apis.test.ts` (and a new
625
+ `tests/api-guards.test.ts` if the file grows unwieldy), README and
626
+ `docs/api-builder.md` updates after implementation.
627
+
628
+ ## 12. Others implementation decisions
629
+
630
+ 1. **Guard typing of `ctx.state`** — full inference (accumulating state
631
+ types across the guard chain) is possible with tuple types but costly in
632
+ complexity. `TState` must declared explicitly by the client via
633
+ the `ApiContext<TUser, TState>` annotation; guards stay loosely typed.
634
+ 2. **Controllers don't need their own `scope`?**
635
+ instance lifecycle stays API-wide. A controller needing its own state is
636
+ a sign it should be a separately mounted API.
637
+ 3. **Response validation** — useful in dev, but doubles validator surface.
638
+ Implemented as opt-in via the `apiBuilder(service, { responses: true })`
639
+ options argument (default off); a return that violates the route's declared
640
+ `200` schema yields `500` (server-contract breach).
641
+ 4. **`x-required-permissions` naming** — vendor extension vs. OpenAPI
642
+ `security` scopes on a custom scheme. Extension is simpler and honest
643
+ (these are not OAuth scopes); the name of the header can be overwrite
644
+ with option provided to the apiBuilder.