@shaferllc/keel 0.83.1 → 0.83.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.83.1",
3
+ "version": "0.83.2",
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",
@@ -52,7 +52,7 @@
52
52
  "title": "Billing",
53
53
  "summary": "Keel Billing is a subscription-billing layer for charging customers, managing subscriptions, and reconciling gateway state through webhooks.",
54
54
  "path": "docs/billing.md",
55
- "example": null
55
+ "example": "docs/examples/billing.ts"
56
56
  },
57
57
  {
58
58
  "slug": "broadcasting",
@@ -115,7 +115,7 @@
115
115
  "title": "CORS",
116
116
  "summary": "Cross-Origin Resource Sharing lets browsers on other origins call your API.",
117
117
  "path": "docs/cors.md",
118
- "example": null
118
+ "example": "docs/examples/cors.ts"
119
119
  },
120
120
  {
121
121
  "slug": "database",
@@ -192,7 +192,7 @@
192
192
  "title": "Built on Hono",
193
193
  "summary": "Keel's HTTP layer is Hono — an ultrafast, web-standard router that runs on Node, Cloudflare Workers, Deno, Bun, and more.",
194
194
  "path": "docs/hono.md",
195
- "example": null
195
+ "example": "docs/examples/hono.ts"
196
196
  },
197
197
  {
198
198
  "slug": "hooks",
@@ -269,21 +269,21 @@
269
269
  "title": "OpenAPI",
270
270
  "summary": "Keel OpenAPI generates an OpenAPI 3 spec from your routes and serves Swagger UI to explore it.",
271
271
  "path": "docs/openapi.md",
272
- "example": null
272
+ "example": "docs/examples/openapi.ts"
273
273
  },
274
274
  {
275
275
  "slug": "orm",
276
276
  "title": "ORM",
277
277
  "summary": "Keel's ORM is a compact active record over the query builder: a model is a class pointed at a table, and its rows come back as typed objects with methods.",
278
278
  "path": "docs/orm.md",
279
- "example": null
279
+ "example": "docs/examples/orm.ts"
280
280
  },
281
281
  {
282
282
  "slug": "packages",
283
283
  "title": "Packages",
284
284
  "summary": "A package is a redistributable slice of a Keel app — routes, a UI, config, migrations, console commands — that installs with a single app.register(...).",
285
285
  "path": "docs/packages.md",
286
- "example": null
286
+ "example": "docs/examples/packages.ts"
287
287
  },
288
288
  {
289
289
  "slug": "pages",
@@ -304,7 +304,7 @@
304
304
  "title": "Query Builder",
305
305
  "summary": "Keel's driver-agnostic query builder — build and run SQL by chaining methods off db(table).",
306
306
  "path": "docs/query-builder.md",
307
- "example": null
307
+ "example": "docs/examples/query-builder.ts"
308
308
  },
309
309
  {
310
310
  "slug": "queues",
@@ -353,7 +353,7 @@
353
353
  "title": "Securing SSR apps",
354
354
  "summary": "Two middlewares harden server-rendered apps: securityHeaders() sets the defensive HTTP headers browsers act on, and csrf() blocks cross-site form submissions.",
355
355
  "path": "docs/security.md",
356
- "example": null
356
+ "example": "docs/examples/security.ts"
357
357
  },
358
358
  {
359
359
  "slug": "sessions",
@@ -367,14 +367,14 @@
367
367
  "title": "Social authentication",
368
368
  "summary": "\"Sign in with GitHub / Google / Discord\" — OAuth 2.0, without an SDK.",
369
369
  "path": "docs/social-auth.md",
370
- "example": null
370
+ "example": "docs/examples/social-auth.ts"
371
371
  },
372
372
  {
373
373
  "slug": "starter-kits",
374
374
  "title": "Starter kits",
375
375
  "summary": "",
376
376
  "path": "docs/starter-kits.md",
377
- "example": null
377
+ "example": "docs/examples/starter-kits.ts"
378
378
  },
379
379
  {
380
380
  "slug": "static-files",
@@ -456,9 +456,9 @@
456
456
  {
457
457
  "slug": "watch",
458
458
  "title": "Watch",
459
- "summary": "Keel Watch is a debug dashboard — a Telescope for Keel.",
459
+ "summary": "Keel Watch is a debug dashboard for Keel apps.",
460
460
  "path": "docs/watch.md",
461
- "example": null
461
+ "example": "docs/examples/watch.ts"
462
462
  }
463
463
  ],
464
464
  "api": [
package/docs/billing.md CHANGED
@@ -205,6 +205,46 @@ resolveBillableUsing(async (customerId) => {
205
205
  });
206
206
  ```
207
207
 
208
+ ## A complete flow
209
+
210
+ From "user signs up" to "they're subscribed", with the fake gateway for tests:
211
+
212
+ ```ts
213
+ import { Model } from "@shaferllc/keel/core";
214
+ import {
215
+ Billable,
216
+ BillingManager,
217
+ FakeGateway,
218
+ setBilling,
219
+ } from "@shaferllc/keel/billing";
220
+
221
+ class User extends Billable(Model) {
222
+ static table = "users";
223
+ declare email: string;
224
+ }
225
+
226
+ // In a test bootstrap:
227
+ const fake = new FakeGateway();
228
+ const manager = new BillingManager({
229
+ default: "fake",
230
+ currency: "usd",
231
+ billableModel: "User",
232
+ webhook: { path: "billing/webhook" },
233
+ gateways: { stripe: { key: "", webhookSecret: "" }, paddle: { key: "", webhookSecret: "" }, fake: {} },
234
+ });
235
+ manager.register("fake", () => fake);
236
+ setBilling(manager);
237
+
238
+ const user = await User.create({ email: "ada@example.com" });
239
+ await user.newSubscription("default", "price_pro").trialDays(14).create();
240
+
241
+ await user.subscribed(); // true
242
+ fake.calls.some((c) => c.method === "createSubscription"); // true
243
+ ```
244
+
245
+ In production you skip the fake manager — `BillingServiceProvider` wires the
246
+ real gateway from `config/billing.ts` and `.env`.
247
+
208
248
  ## Gateway differences
209
249
 
210
250
  | Concern | Stripe | Paddle |
package/docs/changelog.md CHANGED
@@ -4,6 +4,24 @@ All notable changes to Keel are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to
5
5
  adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.83.2] — 2026-07-12
8
+
9
+ ### Added
10
+
11
+ - **More runnable doc examples.** Billing, ORM, query builder, CORS, security,
12
+ social auth, OpenAPI, Watch, packages, Hono, and starter kits now have
13
+ typechecked harnesses under `docs/examples/` — the same ones
14
+ `npm run typecheck:docs` compiles against the published surface.
15
+ - **Worked examples in the guides** — a complete billing subscribe flow (with
16
+ `FakeGateway`), an ORM CRUD+eager-load walkthrough, a production CORS recipe,
17
+ and a clearer starter-kit picker.
18
+
19
+ ### Changed
20
+
21
+ - Watch no longer describes itself by another product's name.
22
+
23
+ [0.83.2]: https://github.com/shaferllc/keel/releases/tag/v0.83.2
24
+
7
25
  ## [0.83.0] — 2026-07-12
8
26
 
9
27
  ### Added
package/docs/cors.md CHANGED
@@ -21,6 +21,34 @@ router.group(() => { /* … */ }).use(cors({ origin: ["https://app.example.com"]
21
21
  With no options, `cors()` reflects the caller's origin — convenient in
22
22
  development. **Lock it down in production** with an explicit allowlist.
23
23
 
24
+ ## A production API group
25
+
26
+ Typical setup for a JSON API served from `api.example.com` and called from a
27
+ SPA on `app.example.com`:
28
+
29
+ ```ts
30
+ // app/Http/Kernel.ts
31
+ import { cors } from "@shaferllc/keel/core";
32
+
33
+ this.use(
34
+ cors({
35
+ origin: ["https://app.example.com"],
36
+ credentials: true, // cookies / Authorization
37
+ exposeHeaders: ["X-Request-Id"],
38
+ }),
39
+ );
40
+ ```
41
+
42
+ During local development, allow any localhost port with a predicate:
43
+
44
+ ```ts
45
+ cors({
46
+ origin: (origin) =>
47
+ origin.startsWith("http://localhost:") || origin === "https://app.example.com",
48
+ credentials: true,
49
+ });
50
+ ```
51
+
24
52
  ## Options
25
53
 
26
54
  ```ts
@@ -0,0 +1,128 @@
1
+ // Type-check harness for docs/billing.md. Compile-only — never executed.
2
+ import { Model, listen } from "@shaferllc/keel/core";
3
+ import {
4
+ Billable,
5
+ BillingServiceProvider,
6
+ BillingManager,
7
+ FakeGateway,
8
+ setBilling,
9
+ resolveBillableUsing,
10
+ type CheckoutSession,
11
+ type GatewayCharge,
12
+ } from "@shaferllc/keel/billing";
13
+
14
+ export class User extends Billable(Model) {
15
+ static override table = "users";
16
+ declare id: number;
17
+ declare email: string;
18
+ declare name: string;
19
+ }
20
+
21
+ declare const user: User;
22
+ declare const paymentMethodId: string;
23
+ declare const successUrl: string;
24
+ declare const cancelUrl: string;
25
+
26
+ export const providers = [BillingServiceProvider];
27
+
28
+ /* -------------------------------- customers ------------------------------- */
29
+
30
+ export async function customers() {
31
+ await user.createAsCustomer();
32
+ user.hasBillingId();
33
+ await user.getCustomerId();
34
+ }
35
+
36
+ /* ------------------------------ subscriptions ----------------------------- */
37
+
38
+ export async function subscribe() {
39
+ await user
40
+ .newSubscription("default", "price_pro")
41
+ .trialDays(14)
42
+ .quantity(3)
43
+ .create(paymentMethodId);
44
+
45
+ const session: CheckoutSession = await user
46
+ .newSubscription("default", "price_pro")
47
+ .checkout({ successUrl, cancelUrl });
48
+ return session;
49
+ }
50
+
51
+ export async function status() {
52
+ await user.subscribed();
53
+ await user.subscribedToPrice("price_pro");
54
+ await user.onTrial();
55
+ const sub = await user.subscription();
56
+ if (!sub) return;
57
+
58
+ sub.active();
59
+ sub.onTrial();
60
+ sub.recurring();
61
+ sub.canceled();
62
+ sub.onGracePeriod();
63
+ sub.ended();
64
+ sub.paused();
65
+ sub.valid();
66
+ sub.hasIncompletePayment();
67
+
68
+ await sub.swap("price_enterprise");
69
+ await sub.updateQuantity(10);
70
+ await sub.incrementQuantity(2);
71
+ await sub.decrementQuantity();
72
+ await sub.cancel();
73
+ await sub.resume();
74
+ await sub.cancelNow();
75
+ await sub.endTrial();
76
+ await sub.extendTrial(new Date("2026-01-01"));
77
+ }
78
+
79
+ /* -------------------------------- charges --------------------------------- */
80
+
81
+ export async function charges() {
82
+ const charge: GatewayCharge = await user.charge(2000, {
83
+ paymentMethod: "pm_1",
84
+ description: "Credits",
85
+ });
86
+ await user.refund(charge.id);
87
+ await user.refund(charge.id, 500);
88
+ await user.checkout("price_onetime", { successUrl, cancelUrl });
89
+ await user.createSetupIntent();
90
+ await user.paymentMethods();
91
+ await user.invoices();
92
+ }
93
+
94
+ /* -------------------------------- webhooks -------------------------------- */
95
+
96
+ export function webhooks() {
97
+ listen("billing.subscription.updated", (e) => {
98
+ void e.gateway;
99
+ void e.subscriptionId;
100
+ });
101
+ listen("billing.webhook.received", (e) => {
102
+ void e.gateway;
103
+ void e.type;
104
+ });
105
+ resolveBillableUsing(async (customerId, _gateway) => {
106
+ return { id: user.id, type: "User" };
107
+ });
108
+ }
109
+
110
+ /* --------------------------------- testing -------------------------------- */
111
+
112
+ export function fakeGateway() {
113
+ const fake = new FakeGateway();
114
+ const manager = new BillingManager({
115
+ default: "fake",
116
+ currency: "usd",
117
+ billableModel: "User",
118
+ webhook: { path: "billing/webhook" },
119
+ gateways: {
120
+ stripe: { key: "", webhookSecret: "" },
121
+ paddle: { key: "", webhookSecret: "" },
122
+ fake: {},
123
+ },
124
+ });
125
+ manager.register("fake", () => fake);
126
+ setBilling(manager);
127
+ return fake;
128
+ }
@@ -0,0 +1,29 @@
1
+ // Type-check harness for docs/cors.md. Compile-only — never executed.
2
+ import type { MiddlewareHandler } from "hono";
3
+ import { cors, type CorsOptions } from "@shaferllc/keel/core";
4
+
5
+ export function kernel(): MiddlewareHandler {
6
+ return cors();
7
+ }
8
+
9
+ export function apiGroup(): MiddlewareHandler {
10
+ return cors({ origin: ["https://app.example.com"] });
11
+ }
12
+
13
+ export function production(): MiddlewareHandler {
14
+ const options: CorsOptions = {
15
+ origin: ["https://app.example.com"],
16
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
17
+ headers: true,
18
+ exposeHeaders: ["X-Request-Id"],
19
+ credentials: true,
20
+ maxAge: 86400,
21
+ };
22
+ return cors(options);
23
+ }
24
+
25
+ export function dynamicOrigin(): MiddlewareHandler {
26
+ return cors({
27
+ origin: (origin) => origin.endsWith(".example.com") || origin.startsWith("http://localhost"),
28
+ });
29
+ }
@@ -0,0 +1,29 @@
1
+ // Type-check harness for docs/hono.md. Compile-only — never executed.
2
+ import type { Ctx } from "@shaferllc/keel/core";
3
+ import { Router, json } from "@shaferllc/keel/core";
4
+
5
+ export function withContext(router: Router) {
6
+ router.get("/users/:id", async (c: Ctx) => {
7
+ const id = c.req.param("id");
8
+ const q = c.req.query("q");
9
+ const auth = c.req.header("authorization");
10
+ void q;
11
+ void auth;
12
+ return c.json({ id });
13
+ });
14
+
15
+ router.get("/ping", (c) => c.text("pong"));
16
+ router.get("/go", (c) => c.redirect("/"));
17
+ }
18
+
19
+ export function ambientHelpers() {
20
+ return json({ ok: true });
21
+ }
22
+
23
+ export function keelContext(c: Ctx) {
24
+ return {
25
+ app: c.get("app"),
26
+ route: c.get("route"),
27
+ subdomains: c.get("subdomains"),
28
+ };
29
+ }
@@ -0,0 +1,35 @@
1
+ // Type-check harness for docs/openapi.md. Compile-only — never executed.
2
+ import { z } from "zod";
3
+ import { Application, Router, validateRequest, type Ctx } from "@shaferllc/keel/core";
4
+ import { OpenApiServiceProvider, apiDoc, OpenApi } from "@shaferllc/keel/openapi";
5
+
6
+ const NewUser = z.object({ email: z.string().email(), age: z.number().min(18) });
7
+ const UserShape = z.object({ id: z.number(), email: z.string().email() });
8
+
9
+ class Users {
10
+ store(_c: Ctx) {
11
+ return { id: 1, email: "a@b.com" };
12
+ }
13
+ }
14
+
15
+ export function install() {
16
+ const app = new Application();
17
+ app.register(OpenApiServiceProvider);
18
+ OpenApi.auth((c) => c.req.header("x-docs-key") === "secret");
19
+ return app;
20
+ }
21
+
22
+ export function document(router: Router) {
23
+ router
24
+ .post("/users", [Users, "store"])
25
+ .name("users.store")
26
+ .config(
27
+ apiDoc({
28
+ summary: "Create a user",
29
+ tags: ["users"],
30
+ request: { body: NewUser },
31
+ responses: { 201: { description: "The created user", schema: UserShape } },
32
+ }),
33
+ )
34
+ .middleware([validateRequest({ body: NewUser })]);
35
+ }
@@ -0,0 +1,30 @@
1
+ // Type-check harness for docs/orm.md. Compile-only — never executed.
2
+ import { Model } from "@shaferllc/keel/core";
3
+
4
+ class Post extends Model {
5
+ static override table = "posts";
6
+ declare id: number;
7
+ declare title: string;
8
+ declare user_id: number;
9
+ }
10
+
11
+ export class User extends Model {
12
+ static override table = "users";
13
+ declare id: number;
14
+ declare email: string;
15
+
16
+ posts() {
17
+ return this.hasMany(Post);
18
+ }
19
+ }
20
+
21
+ export async function map() {
22
+ const user = await User.find(1);
23
+ if (!user) return;
24
+ await user.posts();
25
+ await User.with("posts").where("email", user.email).first();
26
+ await User.create({ email: "ada@example.com" });
27
+ user.email = "grace@example.com";
28
+ await user.save();
29
+ await user.delete();
30
+ }
@@ -0,0 +1,26 @@
1
+ // Type-check harness for docs/packages.md. Compile-only — never executed.
2
+ import { PackageProvider, type Router, type Migration, type PackageCommand } from "@shaferllc/keel/core";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+
6
+ const here = dirname(fileURLToPath(import.meta.url));
7
+
8
+ declare const createInvoicesTable: Migration;
9
+ declare const syncInvoicesCommand: PackageCommand;
10
+ declare function registerBillingRoutes(r: Router): void;
11
+
12
+ export class BillingServiceProvider extends PackageProvider {
13
+ readonly name = "billing";
14
+
15
+ register(): void {
16
+ this.mergeConfig("billing", { enabled: true, path: "billing" });
17
+ this.migrations([createInvoicesTable]);
18
+ this.publishes({ [join(here, "config.stub")]: "config/billing.ts" }, "billing-config");
19
+ this.commands([syncInvoicesCommand]);
20
+ }
21
+
22
+ boot(): void {
23
+ this.assets("billing/assets", join(here, "ui/dist"), { maxAge: 3600 });
24
+ this.routes((r: Router) => registerBillingRoutes(r), { prefix: "billing", as: "billing" });
25
+ }
26
+ }
@@ -0,0 +1,118 @@
1
+ // Type-check harness for docs/query-builder.md. Compile-only — never executed.
2
+ import { db, type Row } from "@shaferllc/keel/core";
3
+
4
+ declare const email: string;
5
+ declare const name: string;
6
+ declare const id: number;
7
+ declare const now: number;
8
+ declare const search: string | undefined;
9
+ declare const includeArchived: boolean;
10
+ declare const key: string;
11
+ declare const value: string;
12
+
13
+ export async function retrieving() {
14
+ await db("users").get();
15
+ await db("users").where("id", 1).first();
16
+ await db("users").where("id", 1).firstOrFail();
17
+ await db("users").find(1);
18
+ await db("users").where("email", email).sole();
19
+ await db("users").where("id", 1).value("email");
20
+ await db("posts").pluck("title");
21
+ await db("tags").orderBy("name").implode("name", ", ");
22
+
23
+ await db("users")
24
+ .orderBy("id")
25
+ .chunk(500, async (rows) => {
26
+ for (const _row of rows) {
27
+ /* process */
28
+ }
29
+ });
30
+ }
31
+
32
+ export async function aggregates() {
33
+ await db("orders").count();
34
+ await db("orders").where("paid", true).sum("total");
35
+ await db("orders").avg("total");
36
+ await db("orders").min("total");
37
+ await db("orders").max("total");
38
+ await db("users").where("email", email).exists();
39
+ await db("users").where("banned", true).doesntExist();
40
+ }
41
+
42
+ export async function selectsAndWheres() {
43
+ db("users").select("id", "email");
44
+ db("users").select("id").addSelect("email");
45
+ db("orders").selectRaw("SUM(total) AS revenue");
46
+ db("users").distinct().select("country");
47
+
48
+ db("users").where("votes", 100);
49
+ db("users").where("votes", ">=", 100);
50
+ db("users").where("name", "like", "T%");
51
+ db("users").where("votes", 100).orWhere("name", "John");
52
+ db("users").whereNot("status", "cancelled");
53
+ db("users").whereIn("id", [1, 2, 3]).whereNotIn("id", [4]);
54
+ db("users").whereNull("deleted_at").whereNotNull("email_verified_at");
55
+ db("products").whereBetween("price", [10, 100]).whereNotBetween("stock", [0, 5]);
56
+ db("posts").whereLike("title", "%keel%");
57
+ db("events").whereColumn("updated_at", ">", "created_at");
58
+ db("users").whereRaw("score >= ? AND score <= ?", [10, 90]);
59
+
60
+ await db("users")
61
+ .where("active", true)
62
+ .where((q) => q.where("role", "admin").orWhere("role", "owner"))
63
+ .get();
64
+ }
65
+
66
+ export async function orderingJoinsWhen() {
67
+ db("users").orderBy("name").orderByDesc("created_at");
68
+ db("posts").latest();
69
+ db("posts").oldest();
70
+ db("posts").orderByRaw("LENGTH(title) DESC");
71
+ db("users").inRandomOrder();
72
+ db("users").reorder("name");
73
+ db("users").limit(10).offset(20);
74
+ db("users").take(10).skip(20);
75
+ db("users").forPage(3, 15);
76
+
77
+ await db("orders")
78
+ .select("user_id")
79
+ .selectRaw("SUM(total) AS spent")
80
+ .groupBy("user_id")
81
+ .having("spent", ">", 1000)
82
+ .get();
83
+
84
+ await db("posts")
85
+ .join("users", "posts.user_id", "users.id")
86
+ .leftJoin("images", "images.post_id", "posts.id")
87
+ .select("posts.title", "users.name")
88
+ .get();
89
+
90
+ await db("users")
91
+ .when(search, (q, term) => q.whereLike("name", `%${term}%`))
92
+ .unless(includeArchived, (q) => q.whereNull("archived_at"))
93
+ .get();
94
+ }
95
+
96
+ export async function writes() {
97
+ await db("users").insert({ email, name });
98
+ await db("users").insertGetId({ email, name });
99
+ await db("logs").insertOrIgnore({ key, value });
100
+ await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]);
101
+
102
+ await db("users").where("id", id).update({ name: "Grace" });
103
+ await db("users").updateOrInsert({ email }, { name });
104
+ await db("posts").where("id", id).increment("views");
105
+ await db("posts").where("id", id).decrement("stock", 3, { updated_at: now });
106
+ await db("counters").incrementEach({ hits: 1, misses: 2 });
107
+
108
+ await db("sessions").where("expires_at", "<", now).delete();
109
+ await db("cache").truncate();
110
+ }
111
+
112
+ export async function typed() {
113
+ type User = { id: number; email: string };
114
+ const row: User | null = await db<User>("users").where("id", 1).first();
115
+ const rows: User[] = await db<User>("users").get();
116
+ const raw: Row[] = await db("users").get();
117
+ return { row, rows, raw };
118
+ }
@@ -0,0 +1,31 @@
1
+ // Type-check harness for docs/security.md. Compile-only — never executed.
2
+ import type { MiddlewareHandler } from "hono";
3
+ import {
4
+ securityHeaders,
5
+ csrf,
6
+ csrfField,
7
+ csrfToken,
8
+ sessionMiddleware,
9
+ } from "@shaferllc/keel/core";
10
+
11
+ export function headers(): MiddlewareHandler[] {
12
+ return [
13
+ securityHeaders(),
14
+ securityHeaders({
15
+ csp: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "https://cdn.example.com"] },
16
+ hsts: { maxAge: 15552000, includeSubDomains: true },
17
+ frameGuard: "DENY",
18
+ }),
19
+ ];
20
+ }
21
+
22
+ export function csrfStack(): MiddlewareHandler[] {
23
+ return [sessionMiddleware(), csrf(), csrf({ except: ["/billing/webhook/*"] })];
24
+ }
25
+
26
+ export function forms() {
27
+ return {
28
+ field: csrfField(),
29
+ token: csrfToken(),
30
+ };
31
+ }
@@ -0,0 +1,76 @@
1
+ // Type-check harness for docs/social-auth.md. Compile-only — never executed.
2
+ import {
3
+ social,
4
+ session,
5
+ redirect,
6
+ request,
7
+ auth,
8
+ ForbiddenException,
9
+ Model,
10
+ config,
11
+ } from "@shaferllc/keel/core";
12
+
13
+ class User extends Model {
14
+ static override table = "users";
15
+ declare id: number;
16
+ declare email: string | null;
17
+ declare name: string | null;
18
+ declare github_id: string;
19
+ declare twitter_id: string;
20
+ }
21
+
22
+ const github = social.github({
23
+ clientId: config("services.github.id") as string,
24
+ clientSecret: config("services.github.secret") as string,
25
+ redirectUri: "https://app.example.com/auth/github/callback",
26
+ });
27
+
28
+ const twitter = social.twitter({
29
+ clientId: config("services.twitter.key") as string,
30
+ clientSecret: config("services.twitter.secret") as string,
31
+ redirectUri: "https://app.example.com/auth/twitter/callback",
32
+ });
33
+
34
+ export function redirectToGithub() {
35
+ const state = social.state();
36
+ session().put("oauth_state", state);
37
+ return redirect(github.redirect({ state }));
38
+ }
39
+
40
+ export async function githubCallback() {
41
+ const state = String(request.query("state") ?? "");
42
+ if (state !== session().pull("oauth_state")) {
43
+ throw new ForbiddenException("Invalid OAuth state");
44
+ }
45
+
46
+ const code = String(request.query("code") ?? "");
47
+ const gh = await github.user(code);
48
+ const existing = await User.query().where("github_id", gh.id).first();
49
+ const user =
50
+ existing ??
51
+ (await User.create({ github_id: gh.id, email: gh.email, name: gh.name }));
52
+
53
+ auth().login(user.id as number);
54
+ return redirect("/dashboard");
55
+ }
56
+
57
+ export async function twitterFlow() {
58
+ const requestToken = await twitter.requestToken();
59
+ session().put("twitter_secret", requestToken.tokenSecret);
60
+ const toProvider = redirect(twitter.redirect(requestToken));
61
+
62
+ const tw = await twitter.user(
63
+ String(request.query("oauth_token") ?? ""),
64
+ String(request.query("oauth_verifier") ?? ""),
65
+ String(session().pull("twitter_secret") ?? ""),
66
+ );
67
+ const user = await User.firstOrCreate({ twitter_id: tw.id }, { name: tw.name });
68
+ auth().login(user.id as number);
69
+ return { toProvider, user };
70
+ }
71
+
72
+ export async function tokenReuse() {
73
+ const code = String(request.query("code") ?? "");
74
+ const token = await github.exchangeCode(code);
75
+ return github.userFromToken(token);
76
+ }
@@ -0,0 +1,14 @@
1
+ // Type-check harness for docs/starter-kits.md. Compile-only — never executed.
2
+ import { TenantModel } from "@shaferllc/keel/teams";
3
+
4
+ class Project extends TenantModel {
5
+ static override table = "projects";
6
+ declare id: number;
7
+ declare name: string;
8
+ }
9
+
10
+ export async function tenantScoped() {
11
+ await Project.all();
12
+ await Project.create({ name: "Hi" });
13
+ await Project.find(1); // null if another team's
14
+ }
@@ -0,0 +1,10 @@
1
+ // Type-check harness for docs/watch.md. Compile-only — never executed.
2
+ import { Application } from "@shaferllc/keel/core";
3
+ import { WatchServiceProvider, Watch } from "@shaferllc/keel/watch";
4
+
5
+ export function install() {
6
+ const app = new Application();
7
+ app.register(WatchServiceProvider);
8
+ Watch.auth((c) => c.req.header("x-watch-key") === "secret");
9
+ return app;
10
+ }
package/docs/orm.md CHANGED
@@ -55,3 +55,42 @@ The ORM is deliberately small — enough for CRUD, relationships, and the common
55
55
  query shapes without an ORM dependency. For a gnarly one-off report, reach for
56
56
  the [query builder](./query-builder.md) or a raw `connection().select(sql)`; the
57
57
  model layer never gets in the way.
58
+
59
+ ## A worked example
60
+
61
+ A blog with authors and posts — enough to see CRUD, a relation, and eager loading
62
+ together:
63
+
64
+ ```ts
65
+ import { Model } from "@shaferllc/keel/core";
66
+
67
+ class Post extends Model {
68
+ static table = "posts";
69
+ static fillable = ["title", "body"];
70
+ declare id: number;
71
+ declare title: string;
72
+ declare user_id: number;
73
+
74
+ author() { return this.belongsTo(User); }
75
+ }
76
+
77
+ class User extends Model {
78
+ static table = "users";
79
+ declare id: number;
80
+ declare email: string;
81
+
82
+ posts() { return this.hasMany(Post); }
83
+ }
84
+
85
+ const ada = await User.create({ email: "ada@example.com" });
86
+ await ada.posts().create({ title: "Notes on engines", body: "…" });
87
+
88
+ const withPosts = await User.with("posts").where("id", ada.id).first();
89
+ for (const post of (withPosts as User & { posts: Post[] }).posts) {
90
+ console.log(post.title);
91
+ }
92
+ ```
93
+
94
+ For the full surface — casts, soft deletes, scopes, polymorphic relations —
95
+ see [Models](./models.md).
96
+
@@ -14,6 +14,20 @@ to finish.
14
14
  | `app` *(default)* | Full-stack: views, sessions, register/login, password reset, two-factor. |
15
15
  | `saas` | `app` plus teams, roles, invitations, billing, and multi-tenancy. |
16
16
 
17
+ ## Pick a kit
18
+
19
+ ```bash
20
+ npm create keeljs@latest my-app # app (default)
21
+ npm create keeljs@latest my-api -- --preset api
22
+ npm create keeljs@latest my-saas -- --preset saas
23
+ npm create keeljs@latest bare -- --preset minimal
24
+ cd my-app && npm install && npm run dev
25
+ ```
26
+
27
+ Then open `http://localhost:3000`. The SaaS kit already has a team switcher,
28
+ invites, and a billing stub wired through [teams](./teams.md) and
29
+ [billing](./billing.md) — start by editing `app/Models` and `routes/web.ts`.
30
+
17
31
  ## Every database, Cloudflare first
18
32
 
19
33
  Each kit ships with all four drivers wired. Switching is `DB_CONNECTION` and nothing
package/docs/watch.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Watch
2
2
 
3
- Keel Watch is a debug dashboard — a Telescope for Keel. It records the requests,
3
+ Keel Watch is a debug dashboard for Keel apps. It records the requests,
4
4
  queries, exceptions, logs, jobs, mail, notifications, cache lookups, events, and
5
5
  scheduled tasks flowing through your app, and shows them in a single-page UI at
6
6
  `/watch`, with every request linked to the queries and logs it produced.
package/llms-full.txt CHANGED
@@ -5764,6 +5764,46 @@ resolveBillableUsing(async (customerId) => {
5764
5764
  });
5765
5765
  ```
5766
5766
 
5767
+ ## A complete flow
5768
+
5769
+ From "user signs up" to "they're subscribed", with the fake gateway for tests:
5770
+
5771
+ ```ts
5772
+ import { Model } from "@shaferllc/keel/core";
5773
+ import {
5774
+ Billable,
5775
+ BillingManager,
5776
+ FakeGateway,
5777
+ setBilling,
5778
+ } from "@shaferllc/keel/billing";
5779
+
5780
+ class User extends Billable(Model) {
5781
+ static table = "users";
5782
+ declare email: string;
5783
+ }
5784
+
5785
+ // In a test bootstrap:
5786
+ const fake = new FakeGateway();
5787
+ const manager = new BillingManager({
5788
+ default: "fake",
5789
+ currency: "usd",
5790
+ billableModel: "User",
5791
+ webhook: { path: "billing/webhook" },
5792
+ gateways: { stripe: { key: "", webhookSecret: "" }, paddle: { key: "", webhookSecret: "" }, fake: {} },
5793
+ });
5794
+ manager.register("fake", () => fake);
5795
+ setBilling(manager);
5796
+
5797
+ const user = await User.create({ email: "ada@example.com" });
5798
+ await user.newSubscription("default", "price_pro").trialDays(14).create();
5799
+
5800
+ await user.subscribed(); // true
5801
+ fake.calls.some((c) => c.method === "createSubscription"); // true
5802
+ ```
5803
+
5804
+ In production you skip the fake manager — `BillingServiceProvider` wires the
5805
+ real gateway from `config/billing.ts` and `.env`.
5806
+
5767
5807
  ## Gateway differences
5768
5808
 
5769
5809
  | Concern | Stripe | Paddle |
@@ -7611,6 +7651,34 @@ router.group(() => { /* … */ }).use(cors({ origin: ["https://app.example.com"]
7611
7651
  With no options, `cors()` reflects the caller's origin — convenient in
7612
7652
  development. **Lock it down in production** with an explicit allowlist.
7613
7653
 
7654
+ ## A production API group
7655
+
7656
+ Typical setup for a JSON API served from `api.example.com` and called from a
7657
+ SPA on `app.example.com`:
7658
+
7659
+ ```ts
7660
+ // app/Http/Kernel.ts
7661
+ import { cors } from "@shaferllc/keel/core";
7662
+
7663
+ this.use(
7664
+ cors({
7665
+ origin: ["https://app.example.com"],
7666
+ credentials: true, // cookies / Authorization
7667
+ exposeHeaders: ["X-Request-Id"],
7668
+ }),
7669
+ );
7670
+ ```
7671
+
7672
+ During local development, allow any localhost port with a predicate:
7673
+
7674
+ ```ts
7675
+ cors({
7676
+ origin: (origin) =>
7677
+ origin.startsWith("http://localhost:") || origin === "https://app.example.com",
7678
+ credentials: true,
7679
+ });
7680
+ ```
7681
+
7614
7682
  ## Options
7615
7683
 
7616
7684
  ```ts
@@ -14934,6 +15002,44 @@ query shapes without an ORM dependency. For a gnarly one-off report, reach for
14934
15002
  the [query builder](./query-builder.md) or a raw `connection().select(sql)`; the
14935
15003
  model layer never gets in the way.
14936
15004
 
15005
+ ## A worked example
15006
+
15007
+ A blog with authors and posts — enough to see CRUD, a relation, and eager loading
15008
+ together:
15009
+
15010
+ ```ts
15011
+ import { Model } from "@shaferllc/keel/core";
15012
+
15013
+ class Post extends Model {
15014
+ static table = "posts";
15015
+ static fillable = ["title", "body"];
15016
+ declare id: number;
15017
+ declare title: string;
15018
+ declare user_id: number;
15019
+
15020
+ author() { return this.belongsTo(User); }
15021
+ }
15022
+
15023
+ class User extends Model {
15024
+ static table = "users";
15025
+ declare id: number;
15026
+ declare email: string;
15027
+
15028
+ posts() { return this.hasMany(Post); }
15029
+ }
15030
+
15031
+ const ada = await User.create({ email: "ada@example.com" });
15032
+ await ada.posts().create({ title: "Notes on engines", body: "…" });
15033
+
15034
+ const withPosts = await User.with("posts").where("id", ada.id).first();
15035
+ for (const post of (withPosts as User & { posts: Post[] }).posts) {
15036
+ console.log(post.title);
15037
+ }
15038
+ ```
15039
+
15040
+ For the full surface — casts, soft deletes, scopes, polymorphic relations —
15041
+ see [Models](./models.md).
15042
+
14937
15043
 
14938
15044
 
14939
15045
  ---
@@ -17630,6 +17736,20 @@ to finish.
17630
17736
  | `app` *(default)* | Full-stack: views, sessions, register/login, password reset, two-factor. |
17631
17737
  | `saas` | `app` plus teams, roles, invitations, billing, and multi-tenancy. |
17632
17738
 
17739
+ ## Pick a kit
17740
+
17741
+ ```bash
17742
+ npm create keeljs@latest my-app # app (default)
17743
+ npm create keeljs@latest my-api -- --preset api
17744
+ npm create keeljs@latest my-saas -- --preset saas
17745
+ npm create keeljs@latest bare -- --preset minimal
17746
+ cd my-app && npm install && npm run dev
17747
+ ```
17748
+
17749
+ Then open `http://localhost:3000`. The SaaS kit already has a team switcher,
17750
+ invites, and a billing stub wired through [teams](./teams.md) and
17751
+ [billing](./billing.md) — start by editing `app/Models` and `routes/web.ts`.
17752
+
17633
17753
  ## Every database, Cloudflare first
17634
17754
 
17635
17755
  Each kit ships with all four drivers wired. Switching is `DB_CONNECTION` and nothing
@@ -21244,7 +21364,7 @@ Vite's `manifest.json`, as produced by the build. You rarely touch it directly
21244
21364
 
21245
21365
  # Watch
21246
21366
 
21247
- Keel Watch is a debug dashboard — a Telescope for Keel. It records the requests,
21367
+ Keel Watch is a debug dashboard for Keel apps. It records the requests,
21248
21368
  queries, exceptions, logs, jobs, mail, notifications, cache lookups, events, and
21249
21369
  scheduled tasks flowing through your app, and shows them in a single-page UI at
21250
21370
  `/watch`, with every request linked to the queries and logs it produced.
package/llms.txt CHANGED
@@ -72,7 +72,7 @@ Userland imports everything from `@shaferllc/keel/core`.
72
72
  - [Validation](https://github.com/shaferllc/keel/blob/main/docs/validation.md): validate() parses request input against a schema and returns typed data.
73
73
  - [Views](https://github.com/shaferllc/keel/blob/main/docs/views.md): Keel renders HTML with Hono JSX — type-safe components that run identically on Node and on Cloudflare Workers (no filesystem templating, so it ports anywhere).
74
74
  - [Vite](https://github.com/shaferllc/keel/blob/main/docs/vite.md): Wire a modern frontend build — bundling, hashed filenames, hot module reload — to Keel's server-rendered HTML, the way modern full-stack frameworks do.
75
- - [Watch](https://github.com/shaferllc/keel/blob/main/docs/watch.md): Keel Watch is a debug dashboard — a Telescope for Keel.
75
+ - [Watch](https://github.com/shaferllc/keel/blob/main/docs/watch.md): Keel Watch is a debug dashboard for Keel apps.
76
76
 
77
77
  ## Examples
78
78
 
@@ -82,6 +82,7 @@ Every topic has a runnable, type-checked example:
82
82
  - [API Resources example](https://github.com/shaferllc/keel/blob/main/docs/examples/api-resources.ts)
83
83
  - [Authentication example](https://github.com/shaferllc/keel/blob/main/docs/examples/authentication.ts)
84
84
  - [Authorization example](https://github.com/shaferllc/keel/blob/main/docs/examples/authorization.ts)
85
+ - [Billing example](https://github.com/shaferllc/keel/blob/main/docs/examples/billing.ts)
85
86
  - [Broadcasting example](https://github.com/shaferllc/keel/blob/main/docs/examples/broadcasting.ts)
86
87
  - [Service Broker example](https://github.com/shaferllc/keel/blob/main/docs/examples/broker.ts)
87
88
  - [Cache example](https://github.com/shaferllc/keel/blob/main/docs/examples/cache.ts)
@@ -89,6 +90,7 @@ Every topic has a runnable, type-checked example:
89
90
  - [The Console example](https://github.com/shaferllc/keel/blob/main/docs/examples/console.ts)
90
91
  - [The Service Container example](https://github.com/shaferllc/keel/blob/main/docs/examples/container.ts)
91
92
  - [Controllers example](https://github.com/shaferllc/keel/blob/main/docs/examples/controllers.ts)
93
+ - [CORS example](https://github.com/shaferllc/keel/blob/main/docs/examples/cors.ts)
92
94
  - [Database example](https://github.com/shaferllc/keel/blob/main/docs/examples/database.ts)
93
95
  - [Debugging example](https://github.com/shaferllc/keel/blob/main/docs/examples/debugging.ts)
94
96
  - [Request Decorators example](https://github.com/shaferllc/keel/blob/main/docs/examples/decorators.ts)
@@ -98,6 +100,7 @@ Every topic has a runnable, type-checked example:
98
100
  - [Hashing & Encryption example](https://github.com/shaferllc/keel/blob/main/docs/examples/hashing.ts)
99
101
  - [Health Checks example](https://github.com/shaferllc/keel/blob/main/docs/examples/health.ts)
100
102
  - [Helpers example](https://github.com/shaferllc/keel/blob/main/docs/examples/helpers.ts)
103
+ - [Built on Hono example](https://github.com/shaferllc/keel/blob/main/docs/examples/hono.ts)
101
104
  - [Lifecycle Hooks example](https://github.com/shaferllc/keel/blob/main/docs/examples/hooks.ts)
102
105
  - [Internationalization example](https://github.com/shaferllc/keel/blob/main/docs/examples/i18n.ts)
103
106
  - [Inertia example](https://github.com/shaferllc/keel/blob/main/docs/examples/inertia.ts)
@@ -108,15 +111,22 @@ Every topic has a runnable, type-checked example:
108
111
  - [Migrations example](https://github.com/shaferllc/keel/blob/main/docs/examples/migrations.ts)
109
112
  - [Models example](https://github.com/shaferllc/keel/blob/main/docs/examples/models.ts)
110
113
  - [Notifications example](https://github.com/shaferllc/keel/blob/main/docs/examples/notification.ts)
114
+ - [OpenAPI example](https://github.com/shaferllc/keel/blob/main/docs/examples/openapi.ts)
115
+ - [ORM example](https://github.com/shaferllc/keel/blob/main/docs/examples/orm.ts)
116
+ - [Packages example](https://github.com/shaferllc/keel/blob/main/docs/examples/packages.ts)
111
117
  - [Pages example](https://github.com/shaferllc/keel/blob/main/docs/examples/pages.ts)
112
118
  - [Service Providers example](https://github.com/shaferllc/keel/blob/main/docs/examples/providers.ts)
119
+ - [Query Builder example](https://github.com/shaferllc/keel/blob/main/docs/examples/query-builder.ts)
113
120
  - [Queues & Jobs example](https://github.com/shaferllc/keel/blob/main/docs/examples/queues.ts)
114
121
  - [Rate Limiting example](https://github.com/shaferllc/keel/blob/main/docs/examples/rate-limiting.ts)
115
122
  - [Redis example](https://github.com/shaferllc/keel/blob/main/docs/examples/redis.ts)
116
123
  - [Request & Response example](https://github.com/shaferllc/keel/blob/main/docs/examples/request-response.ts)
117
124
  - [Routing example](https://github.com/shaferllc/keel/blob/main/docs/examples/routing.ts)
118
125
  - [Task Scheduling example](https://github.com/shaferllc/keel/blob/main/docs/examples/scheduling.ts)
126
+ - [Securing SSR apps example](https://github.com/shaferllc/keel/blob/main/docs/examples/security.ts)
119
127
  - [Sessions example](https://github.com/shaferllc/keel/blob/main/docs/examples/sessions.ts)
128
+ - [Social authentication example](https://github.com/shaferllc/keel/blob/main/docs/examples/social-auth.ts)
129
+ - [Starter kits example](https://github.com/shaferllc/keel/blob/main/docs/examples/starter-kits.ts)
120
130
  - [Static Files example](https://github.com/shaferllc/keel/blob/main/docs/examples/static-files.ts)
121
131
  - [Storage example](https://github.com/shaferllc/keel/blob/main/docs/examples/storage.ts)
122
132
  - [Teams example](https://github.com/shaferllc/keel/blob/main/docs/examples/teams.ts)
@@ -128,6 +138,7 @@ Every topic has a runnable, type-checked example:
128
138
  - [Validation example](https://github.com/shaferllc/keel/blob/main/docs/examples/validation.ts)
129
139
  - [Views example](https://github.com/shaferllc/keel/blob/main/docs/examples/views.tsx)
130
140
  - [Vite example](https://github.com/shaferllc/keel/blob/main/docs/examples/vite.ts)
141
+ - [Watch example](https://github.com/shaferllc/keel/blob/main/docs/examples/watch.ts)
131
142
 
132
143
  ## Optional
133
144
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.83.1",
3
+ "version": "0.83.2",
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",