@shaferllc/keel 0.78.0 → 0.79.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.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Route model binding — a `:user` in the path arrives as a `User`, not a string.
3
+ *
4
+ * bindModel("user", User);
5
+ *
6
+ * router.get("/users/:user", (c) => {
7
+ * const user = boundModel(User); // already fetched. Not a string, not null.
8
+ * return c.json(user);
9
+ * });
10
+ *
11
+ * The row is looked up **before the handler runs**, and a miss is a 404 there and
12
+ * then. That's the whole value: the handler never sees a null, so it never has to
13
+ * remember to check for one — and "forgot the 404" stops being a bug you can write.
14
+ *
15
+ * Bind by another column when the URL isn't the id:
16
+ *
17
+ * bindModel("post", Post, { key: "slug" }); // /posts/hello-world
18
+ *
19
+ * Constrain what's reachable (row-level security — a row outside the scope 404s,
20
+ * so it can't be reached by guessing an id):
21
+ *
22
+ * bindModel("post", Post, { scope: (q, c) => q.where("authorId", currentUserId(c)) });
23
+ *
24
+ * Or resolve it yourself, for anything that isn't a model:
25
+ *
26
+ * bindRoute("tenant", (slug) => tenants.get(slug));
27
+ */
28
+ import type { MiddlewareHandler } from "hono";
29
+ import type { Ctx } from "./http/router.js";
30
+ import type { QueryBuilder, Row } from "./database.js";
31
+ import type { Model } from "./model.js";
32
+ /** A `Model` subclass — the class itself, with its statics. */
33
+ export type ModelClass<T extends Model = Model> = {
34
+ new (row?: Row): T;
35
+ table: string;
36
+ primaryKey: string;
37
+ query(): QueryBuilder;
38
+ };
39
+ export interface BindingOptions<T extends Model = Model> {
40
+ /** The column the URL segment matches. Default: the model's `primaryKey`. */
41
+ key?: string;
42
+ /**
43
+ * Constrain what's findable. A row outside the scope is a 404 — so it can't be
44
+ * reached by guessing an id, which is what makes this security rather than a
45
+ * filter.
46
+ */
47
+ scope?: (query: QueryBuilder, c: Ctx) => QueryBuilder | void;
48
+ /**
49
+ * What to do when nothing matches. Default: throw a 404. Return a value to
50
+ * substitute one instead.
51
+ */
52
+ missing?: (value: string, c: Ctx) => T | never;
53
+ }
54
+ /**
55
+ * Bind a route parameter to a model. `:user` becomes a `User`; a row that doesn't
56
+ * exist (or is outside `scope`) is a 404 before your handler runs.
57
+ */
58
+ export declare function bindModel<T extends Model>(param: string, model: ModelClass<T>, options?: BindingOptions<T>): void;
59
+ /**
60
+ * Bind a route parameter to anything at all — a tenant from a map, a feature flag,
61
+ * a value from an API. Returning `undefined` or `null` is a 404.
62
+ */
63
+ export declare function bindRoute(param: string, resolve: (value: string, c: Ctx) => unknown | Promise<unknown>): void;
64
+ /** Whether a parameter has a binding registered. */
65
+ export declare function hasBinding(param: string): boolean;
66
+ /** Drop every binding — a clean slate between tests. */
67
+ export declare function clearBindings(): void;
68
+ /** The parameter names in a route pattern: `/users/:user/posts/:post` → both. */
69
+ export declare function paramNames(pattern: string): string[];
70
+ /**
71
+ * Resolve this route's bound parameters and stash them on the request. Installed
72
+ * by the HTTP kernel for any route whose pattern has a bound parameter — so the
73
+ * work is skipped entirely for routes that don't.
74
+ *
75
+ * @internal
76
+ */
77
+ export declare function resolveBindings(params: string[], c: Ctx): Promise<void> | void;
78
+ /** Middleware that resolves the bound params of a route pattern. @internal */
79
+ export declare function bindingMiddleware(pattern: string): MiddlewareHandler | undefined;
80
+ /** The raw bound value for a parameter, if any. */
81
+ export declare function boundValue<T = unknown>(param: string, c?: Ctx): T | undefined;
82
+ /**
83
+ * The model bound to this route — already fetched, never null.
84
+ *
85
+ * router.get("/posts/:post", () => {
86
+ * const post = boundModel(Post); // a Post, guaranteed
87
+ * });
88
+ *
89
+ * Pass the parameter name when one model is bound to several (`/users/:user/friends/:friend`).
90
+ */
91
+ export declare function boundModel<T extends Model>(model: ModelClass<T>, param?: string, c?: Ctx): T;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Route model binding — a `:user` in the path arrives as a `User`, not a string.
3
+ *
4
+ * bindModel("user", User);
5
+ *
6
+ * router.get("/users/:user", (c) => {
7
+ * const user = boundModel(User); // already fetched. Not a string, not null.
8
+ * return c.json(user);
9
+ * });
10
+ *
11
+ * The row is looked up **before the handler runs**, and a miss is a 404 there and
12
+ * then. That's the whole value: the handler never sees a null, so it never has to
13
+ * remember to check for one — and "forgot the 404" stops being a bug you can write.
14
+ *
15
+ * Bind by another column when the URL isn't the id:
16
+ *
17
+ * bindModel("post", Post, { key: "slug" }); // /posts/hello-world
18
+ *
19
+ * Constrain what's reachable (row-level security — a row outside the scope 404s,
20
+ * so it can't be reached by guessing an id):
21
+ *
22
+ * bindModel("post", Post, { scope: (q, c) => q.where("authorId", currentUserId(c)) });
23
+ *
24
+ * Or resolve it yourself, for anything that isn't a model:
25
+ *
26
+ * bindRoute("tenant", (slug) => tenants.get(slug));
27
+ */
28
+ import { NotFoundException } from "./exceptions.js";
29
+ import { ctx } from "./request.js";
30
+ const bindings = new Map();
31
+ /**
32
+ * Bind a route parameter to a model. `:user` becomes a `User`; a row that doesn't
33
+ * exist (or is outside `scope`) is a 404 before your handler runs.
34
+ */
35
+ export function bindModel(param, model, options = {}) {
36
+ const column = options.key ?? model.primaryKey;
37
+ bindings.set(param, {
38
+ model: model,
39
+ resolve: async (value, c) => {
40
+ let query = model.query().where(column, value);
41
+ if (options.scope)
42
+ query = options.scope(query, c) ?? query;
43
+ const row = await query.first();
44
+ if (row)
45
+ return new model(row);
46
+ // A miss is a 404 *here*, not a null the handler has to remember to check.
47
+ if (options.missing)
48
+ return options.missing(value, c);
49
+ throw new NotFoundException(`No ${model.name} for "${value}".`);
50
+ },
51
+ });
52
+ }
53
+ /**
54
+ * Bind a route parameter to anything at all — a tenant from a map, a feature flag,
55
+ * a value from an API. Returning `undefined` or `null` is a 404.
56
+ */
57
+ export function bindRoute(param, resolve) {
58
+ bindings.set(param, {
59
+ resolve: async (value, c) => {
60
+ const resolved = await resolve(value, c);
61
+ if (resolved === undefined || resolved === null) {
62
+ throw new NotFoundException(`No match for "${value}".`);
63
+ }
64
+ return resolved;
65
+ },
66
+ });
67
+ }
68
+ /** Whether a parameter has a binding registered. */
69
+ export function hasBinding(param) {
70
+ return bindings.has(param);
71
+ }
72
+ /** Drop every binding — a clean slate between tests. */
73
+ export function clearBindings() {
74
+ bindings.clear();
75
+ }
76
+ /** The parameter names in a route pattern: `/users/:user/posts/:post` → both. */
77
+ export function paramNames(pattern) {
78
+ return [...pattern.matchAll(/:([A-Za-z_][A-Za-z0-9_]*)/g)].map((m) => m[1]);
79
+ }
80
+ /**
81
+ * Resolve this route's bound parameters and stash them on the request. Installed
82
+ * by the HTTP kernel for any route whose pattern has a bound parameter — so the
83
+ * work is skipped entirely for routes that don't.
84
+ *
85
+ * @internal
86
+ */
87
+ export function resolveBindings(params, c) {
88
+ const present = params.filter((p) => bindings.has(p));
89
+ if (!present.length)
90
+ return;
91
+ return (async () => {
92
+ const resolved = {};
93
+ // In series, on purpose: a scoped binding usually depends on one resolved
94
+ // before it (`/users/:user/posts/:post`, where the post is scoped to the user).
95
+ for (const param of present) {
96
+ const value = c.req.param(param);
97
+ if (value === undefined)
98
+ continue;
99
+ resolved[param] = await bindings.get(param).resolve(value, c);
100
+ }
101
+ c.set("bindings", { ...c.get("bindings"), ...resolved });
102
+ })();
103
+ }
104
+ /** Middleware that resolves the bound params of a route pattern. @internal */
105
+ export function bindingMiddleware(pattern) {
106
+ const params = paramNames(pattern);
107
+ if (!params.length)
108
+ return undefined;
109
+ return async (c, next) => {
110
+ await resolveBindings(params, c);
111
+ await next();
112
+ };
113
+ }
114
+ /* -------------------------------- accessors ------------------------------- */
115
+ /** The raw bound value for a parameter, if any. */
116
+ export function boundValue(param, c) {
117
+ const store = (c ?? currentCtx())?.get("bindings");
118
+ return store?.[param];
119
+ }
120
+ /**
121
+ * The model bound to this route — already fetched, never null.
122
+ *
123
+ * router.get("/posts/:post", () => {
124
+ * const post = boundModel(Post); // a Post, guaranteed
125
+ * });
126
+ *
127
+ * Pass the parameter name when one model is bound to several (`/users/:user/friends/:friend`).
128
+ */
129
+ export function boundModel(model, param, c) {
130
+ const ctx = c ?? currentCtx();
131
+ const store = ctx?.get("bindings") ?? {};
132
+ if (param) {
133
+ const value = store[param];
134
+ if (value === undefined) {
135
+ throw new Error(`Nothing is bound to ":${param}". Register it with bindModel("${param}", ${model.name}).`);
136
+ }
137
+ return value;
138
+ }
139
+ // No name given: find the one param bound to this model.
140
+ const matches = Object.entries(store).filter(([name]) => bindings.get(name)?.model === model);
141
+ if (!matches.length) {
142
+ throw new Error(`No route parameter is bound to ${model.name}. Register one with bindModel("<param>", ${model.name}).`);
143
+ }
144
+ if (matches.length > 1) {
145
+ // Two params, same model — we can't guess which one you meant.
146
+ throw new Error(`${matches.length} parameters are bound to ${model.name} (${matches.map(([n]) => `":${n}"`).join(", ")}). ` +
147
+ `Say which: boundModel(${model.name}, "${matches[0][0]}").`);
148
+ }
149
+ return matches[0][1];
150
+ }
151
+ /** The request in scope, or undefined outside one. */
152
+ function currentCtx() {
153
+ try {
154
+ return ctx();
155
+ }
156
+ catch {
157
+ return undefined; // called outside a request
158
+ }
159
+ }
@@ -6,6 +6,7 @@
6
6
  import { Hono } from "hono";
7
7
  import { contextStorage } from "hono/context-storage";
8
8
  import { Config } from "../config.js";
9
+ import { bindingMiddleware } from "../binding.js";
9
10
  import { HttpException, NotFoundException, ValidationException, STATUS_TEXT, } from "../exceptions.js";
10
11
  import { Router } from "./router.js";
11
12
  import { instrument, runRequest, newRequestId, currentRequestId } from "../instrumentation.js";
@@ -142,7 +143,15 @@ export class HttpKernel {
142
143
  for (const [param, rgx] of Object.entries(route.wheres)) {
143
144
  path = path.replace(new RegExp(`:${param}(\\??)`), `:${param}{${rgx}}$1`);
144
145
  }
145
- const middleware = [setRoute, ...route.middleware.map((m) => router.resolveMiddleware(m))];
146
+ // Route model binding: resolve `:user` into a User *before* the handler (and
147
+ // before route middleware, so a policy check can read the bound model). Only
148
+ // routes that actually have params pay for it.
149
+ const binding = bindingMiddleware(route.path);
150
+ const middleware = [
151
+ setRoute,
152
+ ...(binding ? [binding] : []),
153
+ ...route.middleware.map((m) => router.resolveMiddleware(m)),
154
+ ];
146
155
  hono.on(route.methods, [path], ...middleware, honoHandler);
147
156
  }
148
157
  hono.notFound((c) => this.handle(new NotFoundException(`No route for ${c.req.method} ${c.req.path}`), c));
@@ -76,6 +76,8 @@ export type { Ctx, RouteHandler, RouteDefinition, Method, Matcher, MiddlewareRef
76
76
  export { Inertia, inertia, inertiaPageAttr } from "./inertia.js";
77
77
  export type { InertiaPage, InertiaOptions } from "./inertia.js";
78
78
  export { HttpKernel } from "./http/kernel.js";
79
+ export { bindModel, bindRoute, boundModel, boundValue, hasBinding, clearBindings } from "./binding.js";
80
+ export type { BindingOptions, ModelClass } from "./binding.js";
79
81
  export { defineCommand, arg, flag, ConsoleKernel, ConsoleError, parseArgv } from "./console.js";
80
82
  export type { CommandDefinition, CommandContext, AnyCommand, ArgSpec, FlagSpec, ArgsSpec, FlagsSpec, ConsoleKernelOptions, } from "./console.js";
81
83
  export { createUi, stripAnsi } from "./console-ui.js";
@@ -41,6 +41,7 @@ export { instrument, runRequest, currentRequestId, newRequestId, } from "./instr
41
41
  export { Router, Route, RouteGroup, RouteResource, matchers } from "./http/router.js";
42
42
  export { Inertia, inertia, inertiaPageAttr } from "./inertia.js";
43
43
  export { HttpKernel } from "./http/kernel.js";
44
+ export { bindModel, bindRoute, boundModel, boundValue, hasBinding, clearBindings } from "./binding.js";
44
45
  export { defineCommand, arg, flag, ConsoleKernel, ConsoleError, parseArgv } from "./console.js";
45
46
  export { createUi, stripAnsi } from "./console-ui.js";
46
47
  export { createPrompt } from "./console-prompt.js";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.78.0",
3
+ "version": "0.79.0",
4
4
  "description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
5
5
  "repo": "https://github.com/shaferllc/keel",
6
6
  "generated": "run `npm run build:ai` to regenerate",
@@ -643,6 +643,21 @@
643
643
  "kind": "value",
644
644
  "module": "helpers"
645
645
  },
646
+ {
647
+ "name": "BindingOptions",
648
+ "kind": "type",
649
+ "module": "binding"
650
+ },
651
+ {
652
+ "name": "bindModel",
653
+ "kind": "value",
654
+ "module": "binding"
655
+ },
656
+ {
657
+ "name": "bindRoute",
658
+ "kind": "value",
659
+ "module": "binding"
660
+ },
646
661
  {
647
662
  "name": "body",
648
663
  "kind": "value",
@@ -658,6 +673,16 @@
658
673
  "kind": "value",
659
674
  "module": "helpers"
660
675
  },
676
+ {
677
+ "name": "boundModel",
678
+ "kind": "value",
679
+ "module": "binding"
680
+ },
681
+ {
682
+ "name": "boundValue",
683
+ "kind": "value",
684
+ "module": "binding"
685
+ },
661
686
  {
662
687
  "name": "broadcast",
663
688
  "kind": "value",
@@ -783,6 +808,11 @@
783
808
  "kind": "value",
784
809
  "module": "authorization"
785
810
  },
811
+ {
812
+ "name": "clearBindings",
813
+ "kind": "value",
814
+ "module": "binding"
815
+ },
786
816
  {
787
817
  "name": "clearChannels",
788
818
  "kind": "value",
@@ -1413,6 +1443,11 @@
1413
1443
  "kind": "value",
1414
1444
  "module": "social"
1415
1445
  },
1446
+ {
1447
+ "name": "hasBinding",
1448
+ "kind": "value",
1449
+ "module": "binding"
1450
+ },
1416
1451
  {
1417
1452
  "name": "hash",
1418
1453
  "kind": "value",
@@ -1843,6 +1878,11 @@
1843
1878
  "kind": "value",
1844
1879
  "module": "model"
1845
1880
  },
1881
+ {
1882
+ "name": "ModelClass",
1883
+ "kind": "type",
1884
+ "module": "binding"
1885
+ },
1846
1886
  {
1847
1887
  "name": "ModelFactory",
1848
1888
  "kind": "value",
@@ -0,0 +1,83 @@
1
+ // Type-check harness for the route-model-binding section of docs/routing.md.
2
+ // Compile-only — never executed.
3
+ import {
4
+ bindModel,
5
+ bindRoute,
6
+ boundModel,
7
+ boundValue,
8
+ hasBinding,
9
+ clearBindings,
10
+ Model,
11
+ Router,
12
+ make,
13
+ ForbiddenException,
14
+ type Ctx,
15
+ type BindingOptions,
16
+ } from "@shaferllc/keel/core";
17
+ import type { MiddlewareHandler } from "hono";
18
+
19
+ class Post extends Model {
20
+ static override table = "posts";
21
+ declare id: number;
22
+ declare slug: string;
23
+ declare authorId: number;
24
+ }
25
+
26
+ interface Tenant {
27
+ id: number;
28
+ name: string;
29
+ }
30
+
31
+ declare const tenants: Map<string, Tenant>;
32
+ declare function currentUserId(c: Ctx): number;
33
+ declare function edit(c: Ctx): Response;
34
+
35
+ export function basic() {
36
+ bindModel("post", Post);
37
+
38
+ make(Router).get("/posts/:post", (c) => {
39
+ const post: Post = boundModel(Post); // a Post, guaranteed
40
+ return c.json({ id: post.id });
41
+ });
42
+ }
43
+
44
+ export function byAnotherColumn() {
45
+ bindModel("post", Post, { key: "slug" });
46
+ }
47
+
48
+ /** scope is row-level security: a row outside it 404s. */
49
+ export function scoped() {
50
+ const options: BindingOptions<Post> = {
51
+ key: "slug",
52
+ scope: (query, c) => query.where("authorId", currentUserId(c)),
53
+ missing: () => new Post({ id: 0 }),
54
+ };
55
+ bindModel("post", Post, options);
56
+ }
57
+
58
+ export function middlewareSeesTheModel() {
59
+ const mustOwn: MiddlewareHandler = async (c, next) => {
60
+ if (boundModel(Post).authorId !== currentUserId(c)) throw new ForbiddenException();
61
+ await next();
62
+ };
63
+
64
+ make(Router).get("/posts/:post/edit", edit).middleware(mustOwn);
65
+ }
66
+
67
+ export function anythingAtAll() {
68
+ bindRoute("tenant", (slug) => tenants.get(slug));
69
+
70
+ make(Router).get("/t/:tenant", (c) => {
71
+ const tenant = boundValue<Tenant>("tenant");
72
+ return c.json({ name: tenant?.name });
73
+ });
74
+ }
75
+
76
+ export function disambiguate(): [Post, Post] {
77
+ return [boundModel(Post, "post"), boundModel(Post, "original")];
78
+ }
79
+
80
+ export function registry(): boolean {
81
+ clearBindings();
82
+ return hasBinding("post");
83
+ }
package/docs/routing.md CHANGED
@@ -254,6 +254,90 @@ router.any("/webhook", [HookController, "handle"]); // every verb
254
254
  router.route(["GET", "POST"], "/search", handler); // a specific set
255
255
  ```
256
256
 
257
+ ## Route model binding
258
+
259
+ A `:post` in the path can arrive as a **`Post`**, not a string:
260
+
261
+ ```ts
262
+ import { bindModel, boundModel } from "@shaferllc/keel/core";
263
+
264
+ bindModel("post", Post); // once, in a provider
265
+
266
+ router.get("/posts/:post", (c) => {
267
+ const post = boundModel(Post); // already fetched. Not a string, not null.
268
+ return c.json(post);
269
+ });
270
+ ```
271
+
272
+ The row is looked up **before your handler runs**, and a miss is a 404 there and
273
+ then. That's the whole value: the handler never sees a `null`, so it never has to
274
+ remember to check for one — **"forgot the 404" stops being a bug you can write.**
275
+
276
+ Compare what you'd otherwise type in every handler:
277
+
278
+ ```ts
279
+ router.get("/posts/:id", async (c) => {
280
+ const post = await Post.find(c.req.param("id"));
281
+ if (!post) throw new NotFoundException(); // ...every time, forever
282
+ return c.json(post);
283
+ });
284
+ ```
285
+
286
+ ### By another column
287
+
288
+ When the URL isn't the id:
289
+
290
+ ```ts
291
+ bindModel("post", Post, { key: "slug" }); // /posts/hello-world
292
+ ```
293
+
294
+ ### `scope` — this is security, not a filter
295
+
296
+ ```ts
297
+ bindModel("post", Post, {
298
+ scope: (query, c) => query.where("authorId", currentUserId(c)),
299
+ });
300
+ ```
301
+
302
+ A row outside the scope is a **404**, not a 403 and not a filtered list — so it
303
+ cannot be reached by *guessing its id*. That's the difference between row-level
304
+ security and decoration. `/posts/2` doesn't 403 (which would confirm the row
305
+ exists); it simply isn't there.
306
+
307
+ The scope gets the request, so it can depend on who's asking.
308
+
309
+ ### Middleware sees the model
310
+
311
+ Binding runs **before** route middleware, so a policy can read the model rather
312
+ than re-fetching it:
313
+
314
+ ```ts
315
+ const mustOwn: MiddlewareHandler = async (c, next) => {
316
+ if (boundModel(Post).authorId !== currentUserId(c)) throw new ForbiddenException();
317
+ await next();
318
+ };
319
+
320
+ router.get("/posts/:post/edit", edit).middleware(mustOwn);
321
+ ```
322
+
323
+ ### Anything that isn't a model
324
+
325
+ ```ts
326
+ bindRoute("tenant", (slug) => tenants.get(slug)); // undefined ⇒ 404
327
+
328
+ router.get("/t/:tenant", () => {
329
+ const tenant = boundValue<Tenant>("tenant");
330
+ });
331
+ ```
332
+
333
+ ### Notes
334
+
335
+ - An **unbound** param is untouched — still just a string via `c.req.param()`.
336
+ - Two params bound to the same model? Say which: `boundModel(Post, "original")`.
337
+ Guessing would be worse than asking.
338
+ - `missing()` substitutes a value instead of 404ing, if you'd rather.
339
+ - Only routes with parameters pay for any of this.
340
+
257
341
  ## Inspecting routes
258
342
 
259
343
  ```bash
package/llms-full.txt CHANGED
@@ -2138,6 +2138,90 @@ router.any("/webhook", [HookController, "handle"]); // every verb
2138
2138
  router.route(["GET", "POST"], "/search", handler); // a specific set
2139
2139
  ```
2140
2140
 
2141
+ ## Route model binding
2142
+
2143
+ A `:post` in the path can arrive as a **`Post`**, not a string:
2144
+
2145
+ ```ts
2146
+ import { bindModel, boundModel } from "@shaferllc/keel/core";
2147
+
2148
+ bindModel("post", Post); // once, in a provider
2149
+
2150
+ router.get("/posts/:post", (c) => {
2151
+ const post = boundModel(Post); // already fetched. Not a string, not null.
2152
+ return c.json(post);
2153
+ });
2154
+ ```
2155
+
2156
+ The row is looked up **before your handler runs**, and a miss is a 404 there and
2157
+ then. That's the whole value: the handler never sees a `null`, so it never has to
2158
+ remember to check for one — **"forgot the 404" stops being a bug you can write.**
2159
+
2160
+ Compare what you'd otherwise type in every handler:
2161
+
2162
+ ```ts
2163
+ router.get("/posts/:id", async (c) => {
2164
+ const post = await Post.find(c.req.param("id"));
2165
+ if (!post) throw new NotFoundException(); // ...every time, forever
2166
+ return c.json(post);
2167
+ });
2168
+ ```
2169
+
2170
+ ### By another column
2171
+
2172
+ When the URL isn't the id:
2173
+
2174
+ ```ts
2175
+ bindModel("post", Post, { key: "slug" }); // /posts/hello-world
2176
+ ```
2177
+
2178
+ ### `scope` — this is security, not a filter
2179
+
2180
+ ```ts
2181
+ bindModel("post", Post, {
2182
+ scope: (query, c) => query.where("authorId", currentUserId(c)),
2183
+ });
2184
+ ```
2185
+
2186
+ A row outside the scope is a **404**, not a 403 and not a filtered list — so it
2187
+ cannot be reached by *guessing its id*. That's the difference between row-level
2188
+ security and decoration. `/posts/2` doesn't 403 (which would confirm the row
2189
+ exists); it simply isn't there.
2190
+
2191
+ The scope gets the request, so it can depend on who's asking.
2192
+
2193
+ ### Middleware sees the model
2194
+
2195
+ Binding runs **before** route middleware, so a policy can read the model rather
2196
+ than re-fetching it:
2197
+
2198
+ ```ts
2199
+ const mustOwn: MiddlewareHandler = async (c, next) => {
2200
+ if (boundModel(Post).authorId !== currentUserId(c)) throw new ForbiddenException();
2201
+ await next();
2202
+ };
2203
+
2204
+ router.get("/posts/:post/edit", edit).middleware(mustOwn);
2205
+ ```
2206
+
2207
+ ### Anything that isn't a model
2208
+
2209
+ ```ts
2210
+ bindRoute("tenant", (slug) => tenants.get(slug)); // undefined ⇒ 404
2211
+
2212
+ router.get("/t/:tenant", () => {
2213
+ const tenant = boundValue<Tenant>("tenant");
2214
+ });
2215
+ ```
2216
+
2217
+ ### Notes
2218
+
2219
+ - An **unbound** param is untouched — still just a string via `c.req.param()`.
2220
+ - Two params bound to the same model? Say which: `boundModel(Post, "original")`.
2221
+ Guessing would be worse than asking.
2222
+ - `missing()` substitutes a value instead of 404ing, if you'd rather.
2223
+ - Only routes with parameters pay for any of this.
2224
+
2141
2225
  ## Inspecting routes
2142
2226
 
2143
2227
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.78.0",
3
+ "version": "0.79.0",
4
4
  "type": "module",
5
5
  "description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
6
6
  "license": "MIT",
@@ -64,7 +64,8 @@
64
64
  "./cli": {
65
65
  "types": "./dist/core/cli/index.d.ts",
66
66
  "import": "./dist/core/cli/index.js"
67
- }
67
+ },
68
+ "./package.json": "./package.json"
68
69
  },
69
70
  "bin": {
70
71
  "keel-mcp": "./bin/keel-mcp.mjs"