@shaferllc/keel 0.59.0 → 0.68.0

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 (166) hide show
  1. package/AGENTS.md +167 -0
  2. package/README.md +30 -1
  3. package/bin/keel-mcp.mjs +9 -0
  4. package/dist/core/application.d.ts +5 -5
  5. package/dist/core/application.js +14 -2
  6. package/dist/core/auth.d.ts +47 -0
  7. package/dist/core/auth.js +77 -0
  8. package/dist/core/authorization.d.ts +9 -1
  9. package/dist/core/authorization.js +22 -2
  10. package/dist/core/cache.d.ts +82 -5
  11. package/dist/core/cache.js +181 -23
  12. package/dist/core/cli/stubs.d.ts +12 -0
  13. package/dist/core/cli/stubs.js +120 -0
  14. package/dist/core/container.d.ts +20 -0
  15. package/dist/core/container.js +52 -0
  16. package/dist/core/cors.d.ts +29 -0
  17. package/dist/core/cors.js +72 -0
  18. package/dist/core/crypto.d.ts +40 -4
  19. package/dist/core/crypto.js +66 -6
  20. package/dist/core/csrf.d.ts +25 -0
  21. package/dist/core/csrf.js +78 -0
  22. package/dist/core/database.d.ts +49 -4
  23. package/dist/core/database.js +89 -21
  24. package/dist/core/events.d.ts +129 -5
  25. package/dist/core/events.js +165 -7
  26. package/dist/core/health.d.ts +141 -0
  27. package/dist/core/health.js +226 -0
  28. package/dist/core/helpers.d.ts +15 -3
  29. package/dist/core/helpers.js +23 -3
  30. package/dist/core/index.d.ts +33 -18
  31. package/dist/core/index.js +16 -8
  32. package/dist/core/lock.d.ts +139 -0
  33. package/dist/core/lock.js +215 -0
  34. package/dist/core/logger.d.ts +82 -4
  35. package/dist/core/logger.js +141 -23
  36. package/dist/core/mail.d.ts +128 -7
  37. package/dist/core/mail.js +264 -16
  38. package/dist/core/model.d.ts +2 -0
  39. package/dist/core/model.js +16 -14
  40. package/dist/core/provider.d.ts +7 -0
  41. package/dist/core/provider.js +7 -0
  42. package/dist/core/queue.d.ts +134 -9
  43. package/dist/core/queue.js +304 -14
  44. package/dist/core/rate-limit.js +3 -0
  45. package/dist/core/relations.js +13 -13
  46. package/dist/core/request.d.ts +26 -0
  47. package/dist/core/request.js +77 -0
  48. package/dist/core/shield.d.ts +39 -0
  49. package/dist/core/shield.js +60 -0
  50. package/dist/core/social.d.ts +173 -0
  51. package/dist/core/social.js +337 -0
  52. package/dist/core/storage.d.ts +159 -6
  53. package/dist/core/storage.js +287 -7
  54. package/dist/core/tokens.d.ts +74 -0
  55. package/dist/core/tokens.js +155 -0
  56. package/dist/db/d1.d.ts +32 -0
  57. package/dist/db/d1.js +26 -0
  58. package/dist/db/libsql.d.ts +29 -0
  59. package/dist/db/libsql.js +32 -0
  60. package/dist/db/pg.d.ts +29 -0
  61. package/dist/db/pg.js +33 -0
  62. package/dist/mcp/server.d.ts +19 -0
  63. package/dist/mcp/server.js +355 -0
  64. package/docs/ai-manifest.json +2472 -0
  65. package/docs/ai.md +128 -0
  66. package/docs/architecture.md +331 -0
  67. package/docs/authentication.md +453 -0
  68. package/docs/authorization.md +167 -0
  69. package/docs/broadcasting.md +137 -0
  70. package/docs/broker.md +500 -0
  71. package/docs/cache.md +558 -0
  72. package/docs/configuration.md +311 -0
  73. package/docs/console.md +356 -0
  74. package/docs/container.md +467 -0
  75. package/docs/controllers.md +265 -0
  76. package/docs/cors.md +51 -0
  77. package/docs/database.md +530 -0
  78. package/docs/debugging.md +129 -0
  79. package/docs/decorators.md +127 -0
  80. package/docs/errors.md +395 -0
  81. package/docs/events.md +496 -0
  82. package/docs/examples/architecture-app.ts +27 -0
  83. package/docs/examples/authentication.ts +61 -0
  84. package/docs/examples/authorization.ts +79 -0
  85. package/docs/examples/broadcasting.ts +60 -0
  86. package/docs/examples/broker-cache-validate.ts +34 -0
  87. package/docs/examples/broker-fault-tolerance.ts +29 -0
  88. package/docs/examples/broker-middleware.ts +27 -0
  89. package/docs/examples/broker.ts +203 -0
  90. package/docs/examples/cache.ts +222 -0
  91. package/docs/examples/configuration.ts +81 -0
  92. package/docs/examples/container.ts +134 -0
  93. package/docs/examples/controllers.ts +86 -0
  94. package/docs/examples/database.ts +118 -0
  95. package/docs/examples/debugging.ts +41 -0
  96. package/docs/examples/decorators.ts +40 -0
  97. package/docs/examples/errors.ts +121 -0
  98. package/docs/examples/events.ts +204 -0
  99. package/docs/examples/factories.ts +84 -0
  100. package/docs/examples/hashing.ts +71 -0
  101. package/docs/examples/health.ts +94 -0
  102. package/docs/examples/helpers.ts +171 -0
  103. package/docs/examples/hooks.ts +54 -0
  104. package/docs/examples/inertia.ts +81 -0
  105. package/docs/examples/locks.ts +120 -0
  106. package/docs/examples/logger.ts +92 -0
  107. package/docs/examples/mail.ts +160 -0
  108. package/docs/examples/middleware.ts +119 -0
  109. package/docs/examples/migrations.ts +126 -0
  110. package/docs/examples/models.ts +239 -0
  111. package/docs/examples/notification.ts +124 -0
  112. package/docs/examples/providers.ts +123 -0
  113. package/docs/examples/queues.ts +254 -0
  114. package/docs/examples/rate-limiting.ts +42 -0
  115. package/docs/examples/redis.ts +99 -0
  116. package/docs/examples/request-response.ts +197 -0
  117. package/docs/examples/routing.ts +186 -0
  118. package/docs/examples/scheduling.ts +62 -0
  119. package/docs/examples/sessions.ts +102 -0
  120. package/docs/examples/static-files.ts +63 -0
  121. package/docs/examples/storage.ts +132 -0
  122. package/docs/examples/templates.ts +58 -0
  123. package/docs/examples/testing.ts +66 -0
  124. package/docs/examples/transformer.ts +141 -0
  125. package/docs/examples/transformers.ts +49 -0
  126. package/docs/examples/url-builder.ts +86 -0
  127. package/docs/examples/validation.ts +102 -0
  128. package/docs/examples/views.tsx +62 -0
  129. package/docs/examples/vite.ts +106 -0
  130. package/docs/factories.md +166 -0
  131. package/docs/getting-started.md +290 -0
  132. package/docs/hashing.md +259 -0
  133. package/docs/health.md +225 -0
  134. package/docs/helpers.md +347 -0
  135. package/docs/hono.md +186 -0
  136. package/docs/hooks.md +118 -0
  137. package/docs/inertia.md +241 -0
  138. package/docs/locks.md +323 -0
  139. package/docs/logger.md +290 -0
  140. package/docs/mail.md +678 -0
  141. package/docs/middleware.md +425 -0
  142. package/docs/migrations.md +476 -0
  143. package/docs/models.md +810 -0
  144. package/docs/notifications.md +474 -0
  145. package/docs/providers.md +363 -0
  146. package/docs/queues.md +679 -0
  147. package/docs/rate-limiting.md +155 -0
  148. package/docs/redis.md +178 -0
  149. package/docs/request-response.md +953 -0
  150. package/docs/routing.md +804 -0
  151. package/docs/scheduling.md +110 -0
  152. package/docs/security.md +85 -0
  153. package/docs/sessions.md +354 -0
  154. package/docs/social-auth.md +174 -0
  155. package/docs/static-files.md +211 -0
  156. package/docs/storage.md +450 -0
  157. package/docs/templates.md +315 -0
  158. package/docs/testing.md +125 -0
  159. package/docs/transformers.md +381 -0
  160. package/docs/url-builder.md +295 -0
  161. package/docs/validation.md +288 -0
  162. package/docs/views.md +267 -0
  163. package/docs/vite.md +434 -0
  164. package/llms-full.txt +17694 -0
  165. package/llms.txt +116 -0
  166. package/package.json +38 -7
@@ -0,0 +1,381 @@
1
+ # Transformers
2
+
3
+ A model knows the database; a **transformer** knows the API. It's the
4
+ presentation layer between the two — subclass `Transformer`, define one
5
+ `transform()` that maps a value to the exact shape you expose, and get
6
+ `item` / `collection` / `document` for free. No columns leak by accident, no
7
+ relation triggers a surprise query, and the same shape renders everywhere.
8
+ Edge-safe, like everything under it — a transformer leans on nothing but the
9
+ value you hand it.
10
+
11
+ ## Defining a transformer
12
+
13
+ Subclass `Transformer<T>` and implement `transform`. The generic is the type you
14
+ map *from* (often a model); the return is a plain, JSON-ready object:
15
+
16
+ ```ts
17
+ import { Transformer, type Attributes } from "@shaferllc/keel/core";
18
+ import { User } from "../app/Models/User.js";
19
+
20
+ export class UserTransformer extends Transformer<User> {
21
+ transform(user: User): Attributes {
22
+ return {
23
+ id: user.id,
24
+ name: user.name,
25
+ joined: user.created_at,
26
+ };
27
+ }
28
+ }
29
+ ```
30
+
31
+ Generate one with `keel make:transformer User` (→
32
+ `app/Transformers/UserTransformer.ts`). Pass `--model Account` when the class name
33
+ doesn't match the value it maps.
34
+
35
+ ## Transforming
36
+
37
+ Three methods cover every case — one, many, or a full response document:
38
+
39
+ ```ts
40
+ import { json } from "@shaferllc/keel/core";
41
+
42
+ const users = new UserTransformer();
43
+
44
+ json(users.item(user)); // one → { id, name, joined } | null
45
+ json(users.collection(list)); // many → [{ … }, { … }]
46
+ json(users.document(list, { // wrapped, with meta
47
+ meta: { total: list.length },
48
+ })); // → { data: [{ … }], total }
49
+ ```
50
+
51
+ `item` returns `null` for a nullish value, so a not-found lookup passes straight
52
+ through. `collection` maps each value through `transform`. `document` is what you
53
+ usually hand back from a controller — it wraps the payload under a key (`data` by
54
+ default) and merges any top-level `meta` (pagination, counts, links) beside it.
55
+
56
+ ## Conditional fields
57
+
58
+ `when` includes a key only when a condition holds — and *removes the key entirely*
59
+ otherwise, so no `null` leaks into the payload:
60
+
61
+ ```ts
62
+ transform(user: User): Attributes {
63
+ return {
64
+ id: user.id,
65
+ name: user.name,
66
+ email: this.when(String(user.id) === this.viewerId, user.email), // only your own email
67
+ };
68
+ }
69
+ ```
70
+
71
+ For someone else's user, the response is simply `{ id, name }` — the `email` key
72
+ is gone, not `null`. Pass a third argument to substitute a fallback instead of
73
+ omitting, and pass a **thunk** to defer an expensive value until the condition is
74
+ true:
75
+
76
+ ```ts
77
+ token: this.when(fresh, () => mintToken(user), null), // null when not fresh
78
+ ```
79
+
80
+ To gate *several* keys at once, `mergeWhen` returns an object to spread — `{}`
81
+ when the condition is false, so nothing is added:
82
+
83
+ ```ts
84
+ return {
85
+ id: user.id,
86
+ ...this.mergeWhen(user.admin, { role: user.role, permissions: user.permissions }),
87
+ };
88
+ ```
89
+
90
+ Transformers pass the current viewer (or any context) through the constructor —
91
+ they're plain instances:
92
+
93
+ ```ts
94
+ export class UserTransformer extends Transformer<User> {
95
+ constructor(private viewerId: string | null) {
96
+ super();
97
+ }
98
+ // …use this.viewerId in transform()
99
+ }
100
+
101
+ json(new UserTransformer(auth().id()).collection(users));
102
+ ```
103
+
104
+ ## Nesting & relations
105
+
106
+ Embed one transformer inside another by calling it inline — the seam composes:
107
+
108
+ ```ts
109
+ transform(post: Post): Attributes {
110
+ return {
111
+ id: post.id,
112
+ title: post.title,
113
+ author: new UserTransformer(this.viewerId).item(post.author),
114
+ };
115
+ }
116
+ ```
117
+
118
+ But for a [model](./models.md) relation, reach for `whenLoaded` — it includes the
119
+ relation **only if it was eager-loaded**, so a transformer never fires a query
120
+ behind your back:
121
+
122
+ ```ts
123
+ transform(user: User): Attributes {
124
+ return {
125
+ id: user.id,
126
+ name: user.name,
127
+ posts: this.whenLoaded(user, "posts", new PostTransformer()),
128
+ };
129
+ }
130
+ ```
131
+
132
+ `whenLoaded` reads the relation off the model (via the model's `getRelation`, set
133
+ by [`Model.load`](./models.md)), and, if present, runs it through the transformer
134
+ you pass — a `collection` for an array relation, an `item` for a single one. If
135
+ the relation wasn't loaded, the key is omitted. Pass a plain function instead of a
136
+ transformer to map it yourself:
137
+
138
+ ```ts
139
+ roles: this.whenLoaded(user, "roles", (roles) => roles.map((r) => r.name)),
140
+ ```
141
+
142
+ So the caller controls depth by choosing what to load:
143
+
144
+ ```ts
145
+ const users = await User.all();
146
+ await User.load(users, "posts"); // eager-load first
147
+ json(new UserTransformer().collection(users)); // …then posts appear
148
+ ```
149
+
150
+ Without the `load`, the same transformer simply omits `posts` — no N+1, no
151
+ surprise. See [Models](./models.md#eager-loading) for eager loading.
152
+
153
+ ## Response documents
154
+
155
+ `document` builds the envelope most JSON APIs return — a wrapped payload plus
156
+ top-level metadata:
157
+
158
+ ```ts
159
+ const page = await User.all();
160
+ return json(
161
+ new UserTransformer().document(page, {
162
+ meta: { total: page.length, page: 1 },
163
+ }),
164
+ );
165
+ // { "data": [ … ], "total": 42, "page": 1 }
166
+ ```
167
+
168
+ Change the wrapper per class by setting `wrapKey`, or per call with the `key`
169
+ option; set `key: null` to merge a single object's fields to the top level (meta
170
+ included):
171
+
172
+ ```ts
173
+ class UserTransformer extends Transformer<User> {
174
+ wrapKey = "user"; // → { user: { … } }
175
+ }
176
+
177
+ new UserTransformer().document(user, { key: null, meta: { fetchedAt } });
178
+ // { id, name, …, fetchedAt }
179
+ ```
180
+
181
+ `item` and `collection` return the **bare** shape (no wrapper) so they compose
182
+ cleanly when nested; `document` is the one that wraps. Reach for `document` at the
183
+ edge of a response and `item`/`collection` everywhere inside.
184
+
185
+ ## In a controller
186
+
187
+ The whole point is a controller that reads clean:
188
+
189
+ ```ts
190
+ export class UserController {
191
+ async show(c: Ctx) {
192
+ const user = await User.findOrFail(c.req.param("id"));
193
+ return c.json(new UserTransformer(auth().id()).item(user));
194
+ }
195
+
196
+ async index(c: Ctx) {
197
+ const users = await User.all();
198
+ await User.load(users, "posts");
199
+ return c.json(new UserTransformer(auth().id()).document(users));
200
+ }
201
+ }
202
+ ```
203
+
204
+ ## Related
205
+
206
+ Transformers sit downstream of [Models](./models.md) — they shape what a model
207
+ exposes without the model knowing about the API. They pair with the
208
+ [request/response](./request-response.md) helpers (`json`) at the edge, and with
209
+ [authentication](./authentication.md) when a field depends on the viewer.
210
+
211
+ ---
212
+
213
+ ## API reference
214
+
215
+ ### `Transformer<T>`
216
+
217
+ The abstract base. Subclass it, set the generic to the value you map *from*, and
218
+ implement `transform`. Instances are plain — pass request context (a viewer id, a
219
+ locale) through the constructor.
220
+
221
+ ```ts
222
+ class UserTransformer extends Transformer<User> {
223
+ transform(user: User): Attributes {
224
+ return { id: user.id, name: user.name };
225
+ }
226
+ }
227
+ ```
228
+
229
+ #### `transform(item)`
230
+
231
+ `abstract transform(item: T): Attributes`
232
+
233
+ Maps one value to its API shape — the only method a subclass must implement.
234
+ Returns a plain object; use the helpers below to add fields conditionally.
235
+
236
+ ```ts
237
+ transform(user: User): Attributes {
238
+ return { id: user.id, name: user.name };
239
+ }
240
+ ```
241
+
242
+ **Notes:** called once per value by `item`/`collection`. Its result is *pruned*
243
+ (any `when`/`whenLoaded`-omitted keys are stripped, recursively) before you see
244
+ it, so an omitted key is truly absent — not `undefined`.
245
+
246
+ #### `item(value)`
247
+
248
+ `item(value: T | null | undefined): Attributes | null`
249
+
250
+ Transforms a single value. A nullish value passes straight through as `null`.
251
+
252
+ ```ts
253
+ new UserTransformer().item(user); // { id, name }
254
+ new UserTransformer().item(null); // null
255
+ ```
256
+
257
+ **Notes:** returns the **bare** shape (no `wrapKey` wrapper) — wrap with
258
+ `document` when returning a response. `null` in, `null` out, so a `findOrNull`
259
+ result needs no guard.
260
+
261
+ #### `collection(values)`
262
+
263
+ `collection(values: T[]): Attributes[]`
264
+
265
+ Transforms an array, each value through `transform`.
266
+
267
+ ```ts
268
+ new UserTransformer().collection(await User.all());
269
+ ```
270
+
271
+ **Notes:** returns a bare array (no wrapper). Empty in, empty out. Combine with
272
+ `Model.load` beforehand so any `whenLoaded` relations are present.
273
+
274
+ #### `document(value, options?)`
275
+
276
+ `document(value: T | T[] | null | undefined, options?: DocumentOptions): Attributes`
277
+
278
+ Builds a response document: the transformed payload wrapped under a key, with
279
+ optional top-level `meta`. An array becomes a list; anything else a single object.
280
+
281
+ ```ts
282
+ new UserTransformer().document(users, { meta: { total: users.length } });
283
+ // { data: [ … ], total }
284
+ ```
285
+
286
+ **Notes:** the wrapper key is `options.key` if given, else the instance `wrapKey`
287
+ (default `"data"`). With `key: null` a single object's fields merge to the top
288
+ level alongside `meta`; an array with no key still gets a `data` home (meta can't
289
+ share a level with a bare array).
290
+
291
+ #### `wrapKey`
292
+
293
+ `wrapKey: string | null`
294
+
295
+ The key `document` wraps under by default. Override per subclass; `null` disables
296
+ wrapping.
297
+
298
+ ```ts
299
+ class UserTransformer extends Transformer<User> {
300
+ wrapKey = "user";
301
+ }
302
+ ```
303
+
304
+ **Notes:** defaults to `"data"`. Only consulted by `document` — `item` and
305
+ `collection` never wrap.
306
+
307
+ #### `when(condition, value, fallback?)`
308
+
309
+ `protected when<V>(condition: unknown, value: V | (() => V), fallback?: V): V`
310
+
311
+ Include `value` when `condition` is truthy; otherwise **omit the key** — or use
312
+ `fallback` if you pass one. `value` may be a thunk, evaluated only when the
313
+ condition holds.
314
+
315
+ ```ts
316
+ email: this.when(isSelf, user.email), // key vanishes for others
317
+ token: this.when(fresh, () => mint(), null), // null fallback, lazy value
318
+ ```
319
+
320
+ **Notes:** a helper for use inside `transform`. With no `fallback`, a false
321
+ condition removes the key entirely (via a sentinel that pruning strips) rather
322
+ than emitting `null`. The thunk form defers work you don't want to pay for when
323
+ the field is hidden.
324
+
325
+ #### `mergeWhen(condition, values)`
326
+
327
+ `protected mergeWhen(condition: unknown, values: Attributes | (() => Attributes)): Attributes`
328
+
329
+ The merge counterpart to `when` — returns `values` (spread several keys in) when
330
+ `condition` holds, or `{}` when it doesn't.
331
+
332
+ ```ts
333
+ return { id: u.id, ...this.mergeWhen(u.admin, { role: u.role, flags: u.flags }) };
334
+ ```
335
+
336
+ **Notes:** meant to be spread (`...`). `values` may be a thunk, deferred until the
337
+ condition is true. Use it when a *group* of fields appears together.
338
+
339
+ #### `whenLoaded(model, name, map?)`
340
+
341
+ `protected whenLoaded<V>(model: unknown, name: string, map?: Transformer | ((value) => unknown)): V`
342
+
343
+ Include a relation only if it was already loaded — **never fires a query**. Reads
344
+ the relation off the model and, if present, runs it through `map` (a transformer
345
+ or a function). Omits the key when it isn't loaded.
346
+
347
+ ```ts
348
+ posts: this.whenLoaded(user, "posts", new PostTransformer()),
349
+ roles: this.whenLoaded(user, "roles", (rs) => rs.map((r) => r.name)),
350
+ ```
351
+
352
+ **Notes:** resolves the relation via the model's `getRelation` (set by
353
+ `Model.load`) or a plain loaded property — a relation *method* is never mistaken
354
+ for a value. With a `Transformer`, an array relation goes through `collection` and
355
+ a single one through `item`. With no `map`, the raw loaded value is used.
356
+
357
+ ### `Attributes`
358
+
359
+ ```ts
360
+ type Attributes = Record<string, unknown>;
361
+ ```
362
+
363
+ The shape a transformer produces — a plain, JSON-ready object. `transform` returns
364
+ one; so do `item` and `document`.
365
+
366
+ ### `DocumentOptions`
367
+
368
+ ```ts
369
+ interface DocumentOptions {
370
+ key?: string | null; // wrap under this key; null disables. Defaults to wrapKey.
371
+ meta?: Attributes; // top-level fields merged beside the payload.
372
+ }
373
+ ```
374
+
375
+ Controls `document`'s envelope. `key` overrides the instance `wrapKey` for one
376
+ call; `meta` supplies pagination, counts, or links at the top level.
377
+
378
+ ```ts
379
+ new UserTransformer().document(users, { key: "records", meta: { total: 42 } });
380
+ // { records: [ … ], total: 42 }
381
+ ```
@@ -0,0 +1,295 @@
1
+ # URL Builder
2
+
3
+ Generate URLs from **named routes** so paths live in one place. Name a route,
4
+ then build its URL by name — with params and query strings — and never hardcode
5
+ a path again.
6
+
7
+ The URL builder lives on the `Router`. In an app you resolve it from the
8
+ container (`app.make(Router)`); the examples below assume a `router` in scope.
9
+
10
+ ## Building URLs
11
+
12
+ ```ts
13
+ router.get("/users/:id", [UserController, "show"]).name("users.show");
14
+
15
+ router.url("users.show", { id: 42 });
16
+ // "/users/42"
17
+
18
+ router.url("users.show", { id: 42 }, { qs: { tab: "posts", page: 2 } });
19
+ // "/users/42?tab=posts&page=2"
20
+ ```
21
+
22
+ Params are matched by name against the `:param` segments in the route path, and
23
+ each value is `encodeURIComponent`-escaped, so slashes and spaces are safe:
24
+
25
+ ```ts
26
+ router.get("/files/:name", [FileController]).name("files.show");
27
+ router.url("files.show", { name: "a/b c.txt" });
28
+ // "/files/a%2Fb%20c.txt"
29
+ ```
30
+
31
+ Query values are coerced to strings (numbers become their decimal form), so you
32
+ can pass `{ page: 2 }` and get `page=2`. An empty `qs` (`{}`) adds no `?`.
33
+
34
+ ### Optional params
35
+
36
+ A trailing `:param?` segment is dropped when you don't pass it:
37
+
38
+ ```ts
39
+ router.get("/posts/:id?", [PostController, "show"]).name("posts.show");
40
+
41
+ router.url("posts.show", { id: 7 }); // "/posts/7"
42
+ router.url("posts.show", {}); // "/posts" (optional segment stripped)
43
+ ```
44
+
45
+ Any required `:param` you forget to supply is stripped too — so a missing param
46
+ silently produces a shorter path rather than throwing. Pass every required param.
47
+
48
+ ### Errors
49
+
50
+ `url()` throws `No route named [name].` if no registered route carries that name.
51
+ Names come from `.name()` / `.as()`, so name a route before you build its URL.
52
+
53
+ ```ts
54
+ router.url("nope"); // throws: No route named [nope].
55
+ ```
56
+
57
+ ## Signed URLs
58
+
59
+ A signed URL carries a tamper-proof signature — perfect for one-off links
60
+ (email confirmations, unsubscribe, downloads) where you want to trust the
61
+ parameters without a database lookup. Signing uses `config('app.key')`, so set
62
+ an `APP_KEY`:
63
+
64
+ ```ts
65
+ // generate (async — uses Web Crypto, works on Node and the edge)
66
+ const url = await router.signedUrl("download", { id: 7 });
67
+ const expiring = await router.signedUrl("download", { id: 7 }, { expiresIn: 3600 });
68
+ ```
69
+
70
+ `signedUrl` builds the URL exactly like `url()`, appends any `qs` you pass, adds
71
+ an `expires` timestamp when `expiresIn` is set, then HMAC-SHA256 signs the whole
72
+ path-plus-query with the app key and appends a `signature` parameter. The result
73
+ looks like:
74
+
75
+ ```
76
+ /download/7?expires=1710000000&signature=8f3c…
77
+ ```
78
+
79
+ Verify the incoming request in your handler or a middleware:
80
+
81
+ ```ts
82
+ show() {
83
+ if (!(await router.hasValidSignature())) {
84
+ return response.abort("Invalid or expired link", 403);
85
+ }
86
+ // …trusted params
87
+ }
88
+ ```
89
+
90
+ `hasValidSignature()` reads the current request, strips the `signature`
91
+ parameter, re-signs the remaining path and query with the app key, and compares.
92
+ It returns `false` if the signature is missing, the URL was tampered with, or an
93
+ `expires` timestamp has passed.
94
+
95
+ Both `signedUrl()` and `hasValidSignature()` throw
96
+ `Signed URLs require config('app.key').` when no app key is configured — set
97
+ `APP_KEY` before you use either.
98
+
99
+ ## Parameter constraints & matchers
100
+
101
+ Route params can be constrained with a regex so a URL only matches when the
102
+ segment fits. A constraint is a `Matcher` — a `RegExp`, a raw source string, or
103
+ a `{ match: RegExp }` object — passed to `.where()`:
104
+
105
+ ```ts
106
+ router.get("/users/:id", [UserController]).where("id", /\d+/);
107
+ router.get("/p/:slug", [PostController]).where("slug", { match: /[a-z0-9-]+/ });
108
+ ```
109
+
110
+ The `matchers` export bundles the common patterns so you don't rewrite them:
111
+
112
+ ```ts
113
+ import { matchers } from "@shaferllc/keel/core";
114
+
115
+ router.get("/users/:id", [UserController]).where("id", matchers.number());
116
+ router.get("/t/:id", [TeamController]).where("id", matchers.uuid());
117
+ router.get("/p/:slug", [PostController]).where("slug", matchers.slug());
118
+ router.get("/c/:code", [CodeController]).where("code", matchers.alpha());
119
+ ```
120
+
121
+ The same helpers hang off the router instance as `router.matchers`, so you can
122
+ reach them without a separate import. `matchers` is not part of URL *generation*
123
+ — it shapes which URLs a route will *match* — but the two work together: build a
124
+ URL with `url()` and it will satisfy the constraint if your params are the right
125
+ shape.
126
+
127
+ ## Notes
128
+
129
+ - Signatures cover the path **and** query string, so changing any parameter
130
+ invalidates the link.
131
+ - The signing key must be stable and secret. Set `APP_KEY` to a long random
132
+ value (and keep it out of source control).
133
+
134
+ ---
135
+
136
+ ## API reference
137
+
138
+ ### Router (URL methods)
139
+
140
+ You get the `Router` from the container (`app.make(Router)`); in tests it's
141
+ constructed directly as `new Router(container)`. These three methods make up the
142
+ URL-building surface.
143
+
144
+ #### `url(name, params?, options?)`
145
+
146
+ `url(name: string, params?: Record<string, string | number>, options?: UrlOptions): string`
147
+
148
+ Builds the path for a named route, substituting `:params` and appending an
149
+ optional query string.
150
+
151
+ ```ts
152
+ router.url("users.show", { id: 42 }, { qs: { tab: "posts" } });
153
+ // "/users/42?tab=posts"
154
+ ```
155
+
156
+ **Notes:** `params` defaults to `{}`, `options` to `{}`. Values are
157
+ `encodeURIComponent`-escaped; query values are stringified. Optional (`:id?`) and
158
+ any unsupplied required params are stripped from the path. Throws
159
+ `No route named [name].` if the name is unknown. Each `:param` is replaced once,
160
+ so a param that appears twice in a single path only substitutes its first
161
+ occurrence — avoid repeating a param name in one route.
162
+
163
+ #### `signedUrl(name, params?, options?)`
164
+
165
+ `signedUrl(name: string, params?: Record<string, string | number>, options?: SignedUrlOptions): Promise<string>`
166
+
167
+ Like `url()`, but HMAC-SHA256 signs the path-plus-query with `config('app.key')`
168
+ and appends a `signature` parameter, yielding a tamper-proof link.
169
+
170
+ ```ts
171
+ const link = await router.signedUrl("download", { id: 7 }, { expiresIn: 3600 });
172
+ ```
173
+
174
+ **Notes:** async (Web Crypto — Node and edge). `expiresIn` is seconds from now;
175
+ it adds an `expires` unix-second timestamp that is covered by the signature.
176
+ Throws `Signed URLs require config('app.key').` if no app key is set. Reserve the
177
+ `signature` and `expires` query keys — passing them via `options.qs` collides
178
+ with the ones this method adds.
179
+
180
+ #### `hasValidSignature()`
181
+
182
+ `hasValidSignature(): Promise<boolean>`
183
+
184
+ Verifies the signature on the current request: re-signs the path and query
185
+ (minus `signature`) and checks it matches, honoring any `expires` timestamp.
186
+
187
+ ```ts
188
+ if (!(await router.hasValidSignature())) {
189
+ return response.abort("Invalid or expired link", 403);
190
+ }
191
+ ```
192
+
193
+ **Notes:** reads the ambient request, so call it inside a handler/middleware.
194
+ Returns `false` when the `signature` param is absent, the recomputed HMAC
195
+ differs, or `expires` is in the past. Throws
196
+ `Signed URLs require config('app.key').` if no app key is set.
197
+
198
+ ### `matchers`
199
+
200
+ An object of built-in parameter-constraint patterns. Each is a zero-arg function
201
+ returning a fresh `RegExp`, suitable as the `Matcher` argument to `.where()`.
202
+ Also exposed on the router as `router.matchers`.
203
+
204
+ #### `matchers.number()`
205
+
206
+ `number(): RegExp`
207
+
208
+ Matches one or more digits — `/\d+/`.
209
+
210
+ ```ts
211
+ router.get("/users/:id", [UserController]).where("id", matchers.number());
212
+ ```
213
+
214
+ #### `matchers.uuid()`
215
+
216
+ `uuid(): RegExp`
217
+
218
+ Matches a canonical 8-4-4-4-12 hex UUID (case-insensitive).
219
+
220
+ ```ts
221
+ router.get("/t/:id", [TeamController]).where("id", matchers.uuid());
222
+ ```
223
+
224
+ #### `matchers.slug()`
225
+
226
+ `slug(): RegExp`
227
+
228
+ Matches a lowercase slug — `[a-z0-9]+` groups joined by single hyphens
229
+ (`/[a-z0-9]+(?:-[a-z0-9]+)*/`).
230
+
231
+ ```ts
232
+ router.get("/p/:slug", [PostController]).where("slug", matchers.slug());
233
+ ```
234
+
235
+ #### `matchers.alpha()`
236
+
237
+ `alpha(): RegExp`
238
+
239
+ Matches one or more ASCII letters — `/[a-zA-Z]+/`.
240
+
241
+ ```ts
242
+ router.get("/c/:code", [CodeController]).where("code", matchers.alpha());
243
+ ```
244
+
245
+ ### Interfaces & types
246
+
247
+ #### `UrlOptions`
248
+
249
+ ```ts
250
+ interface UrlOptions {
251
+ qs?: Record<string, string | number>;
252
+ }
253
+ ```
254
+
255
+ The options bag for `url()`. `qs` becomes the query string; each value is
256
+ stringified. Use it to tack a query onto a generated URL.
257
+
258
+ ```ts
259
+ const opts: UrlOptions = { qs: { page: 2, tab: "posts" } };
260
+ router.url("users.show", { id: 1 }, opts);
261
+ ```
262
+
263
+ #### `SignedUrlOptions`
264
+
265
+ ```ts
266
+ interface SignedUrlOptions extends UrlOptions {
267
+ /** Expiry in seconds from now. */
268
+ expiresIn?: number;
269
+ }
270
+ ```
271
+
272
+ Extends `UrlOptions` with `expiresIn` for `signedUrl()`. With `expiresIn` set,
273
+ the signed link stops validating after that many seconds.
274
+
275
+ ```ts
276
+ const opts: SignedUrlOptions = { qs: { plan: "pro" }, expiresIn: 3600 };
277
+ await router.signedUrl("download", { id: 7 }, opts);
278
+ ```
279
+
280
+ #### `Matcher`
281
+
282
+ ```ts
283
+ type Matcher = RegExp | string | { match: RegExp };
284
+ ```
285
+
286
+ A route-parameter constraint accepted by `.where()`: a `RegExp`, a raw regex
287
+ *source* string, or a `{ match: RegExp }` wrapper. The `matchers` helpers return
288
+ the `RegExp` form.
289
+
290
+ ```ts
291
+ const a: Matcher = /\d+/;
292
+ const b: Matcher = "[0-9]+";
293
+ const c: Matcher = { match: /[a-z-]+/ };
294
+ router.get("/x/:id", [XController]).where("id", a);
295
+ ```