@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.
- package/dist/core/binding.d.ts +91 -0
- package/dist/core/binding.js +159 -0
- package/dist/core/http/kernel.js +10 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +1 -0
- package/docs/ai-manifest.json +41 -1
- package/docs/examples/binding.ts +83 -0
- package/docs/routing.md +84 -0
- package/llms-full.txt +84 -0
- package/package.json +3 -2
|
@@ -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
|
+
}
|
package/dist/core/http/kernel.js
CHANGED
|
@@ -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
|
-
|
|
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));
|
package/dist/core/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/core/index.js
CHANGED
|
@@ -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";
|
package/docs/ai-manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shaferllc/keel",
|
|
3
|
-
"version": "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.
|
|
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"
|