@shaferllc/keel 0.83.1 → 0.83.3

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/README.md CHANGED
@@ -161,6 +161,8 @@ src/db/ Database adapters (D1, Postgres, libSQL)
161
161
  src/api/ CRUD REST resources from a model
162
162
  src/openapi/ Generates an OpenAPI spec from the routes
163
163
  src/billing/ Subscription billing — Stripe + Paddle
164
+ src/gates/ Signup gates — invite codes & email allowlist
165
+ src/hosting/ Cloudflare / dump / secrets helpers for hosted apps
164
166
  src/watch/ The debug dashboard
165
167
  src/mcp/ The MCP server (docs + API for AI agents)
166
168
  src/vite/ The Vite plugin
@@ -203,7 +205,8 @@ See [docs/architecture.md](./docs/architecture.md) for the full picture.
203
205
  | [Lifecycle Hooks](./docs/hooks.md) | onReady/onShutdown, graceful shutdown, onRoute |
204
206
  | [Sessions](./docs/sessions.md) | Cookie-backed sessions, flash messages |
205
207
  | [Authentication](./docs/authentication.md) | Session auth, guards, user provider |
206
- | [Authorization](./docs/authorization.md) | Gates & policies, `can`/`authorize` |
208
+ | [Gates](./docs/gates.md) | Signup gating — invite codes & email allowlist |
209
+ | [Authorization](./docs/authorization.md) | Ability gates & policies, `can`/`authorize` |
207
210
  | [Database](./docs/database.md) | Driver-agnostic query builder |
208
211
  | [Models](./docs/models.md) | Active-record: find/create/save, casts, relations |
209
212
  | [Migrations](./docs/migrations.md) | Schema builder + migrator, dialect-aware |
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Keel Gates — private-alpha / waitlist signup gating.
3
+ *
4
+ * import { GatesServiceProvider, canRegister, redeemInvite } from "@shaferllc/keel/gates";
5
+ *
6
+ * Not the same as team invitations in `@shaferllc/keel/teams`.
7
+ */
8
+ export { GatesServiceProvider } from "./provider.js";
9
+ export { InviteCode, EmailAllowlist, canRegister, redeemInvite, gatesMigration, } from "./models.js";
10
+ export type { GateCheck } from "./models.js";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Keel Gates — private-alpha / waitlist signup gating.
3
+ *
4
+ * import { GatesServiceProvider, canRegister, redeemInvite } from "@shaferllc/keel/gates";
5
+ *
6
+ * Not the same as team invitations in `@shaferllc/keel/teams`.
7
+ */
8
+ export { GatesServiceProvider } from "./provider.js";
9
+ export { InviteCode, EmailAllowlist, canRegister, redeemInvite, gatesMigration, } from "./models.js";
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Signup gates — invite codes + email allowlist for private alpha / waitlist.
3
+ * Distinct from team invitations (`@shaferllc/keel/teams`).
4
+ */
5
+ import { Model } from "../core/model.js";
6
+ import type { Migration } from "../core/migrations.js";
7
+ export declare class InviteCode extends Model {
8
+ static table: string;
9
+ static fillable: string[];
10
+ static timestamps: boolean;
11
+ id: number;
12
+ code: string;
13
+ max_uses: number;
14
+ uses: number;
15
+ created_by: number | null;
16
+ expires_at: string | null;
17
+ }
18
+ export declare class EmailAllowlist extends Model {
19
+ static table: string;
20
+ static fillable: string[];
21
+ static timestamps: boolean;
22
+ id: number;
23
+ email: string;
24
+ created_by: number | null;
25
+ }
26
+ export type GateCheck = {
27
+ ok: true;
28
+ via: "allowlist" | "code";
29
+ invite?: InviteCode;
30
+ } | {
31
+ ok: false;
32
+ reason: string;
33
+ };
34
+ /** Private alpha: allowlisted email OR a valid invite code. */
35
+ export declare function canRegister(email: string, inviteCode?: string): Promise<GateCheck>;
36
+ export declare function redeemInvite(invite: InviteCode): Promise<void>;
37
+ /**
38
+ * Schema for invite codes + allowlist.
39
+ * Uses IF NOT EXISTS so apps that already created these tables (e.g. early
40
+ * keel-cloud) can still register the provider safely.
41
+ */
42
+ export declare function gatesMigration(): Migration;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Signup gates — invite codes + email allowlist for private alpha / waitlist.
3
+ * Distinct from team invitations (`@shaferllc/keel/teams`).
4
+ */
5
+ import { Model } from "../core/model.js";
6
+ export class InviteCode extends Model {
7
+ static table = "invite_codes";
8
+ static fillable = ["code", "max_uses", "uses", "created_by", "expires_at"];
9
+ static timestamps = true;
10
+ }
11
+ export class EmailAllowlist extends Model {
12
+ static table = "email_allowlist";
13
+ static fillable = ["email", "created_by"];
14
+ static timestamps = true;
15
+ }
16
+ /** Private alpha: allowlisted email OR a valid invite code. */
17
+ export async function canRegister(email, inviteCode) {
18
+ const normalized = email.toLowerCase().trim();
19
+ const allowed = await EmailAllowlist.newQuery().where("email", normalized).first();
20
+ if (allowed)
21
+ return { ok: true, via: "allowlist" };
22
+ const code = String(inviteCode ?? "").trim();
23
+ if (!code) {
24
+ return { ok: false, reason: "Registration requires an invite code or allowlisted email." };
25
+ }
26
+ const invite = await InviteCode.newQuery().where("code", code).first();
27
+ if (!invite)
28
+ return { ok: false, reason: "That invite code is invalid." };
29
+ if (invite.expires_at && new Date(invite.expires_at).getTime() < Date.now()) {
30
+ return { ok: false, reason: "That invite code has expired." };
31
+ }
32
+ if (invite.uses >= invite.max_uses) {
33
+ return { ok: false, reason: "That invite code has already been used up." };
34
+ }
35
+ return { ok: true, via: "code", invite };
36
+ }
37
+ export async function redeemInvite(invite) {
38
+ await invite.update({ uses: invite.uses + 1 });
39
+ }
40
+ /**
41
+ * Schema for invite codes + allowlist.
42
+ * Uses IF NOT EXISTS so apps that already created these tables (e.g. early
43
+ * keel-cloud) can still register the provider safely.
44
+ */
45
+ export function gatesMigration() {
46
+ return {
47
+ name: "gates_00_invite_allowlist",
48
+ async up(schema) {
49
+ await schema.raw(`
50
+ CREATE TABLE IF NOT EXISTS invite_codes (
51
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
52
+ code VARCHAR(255) NOT NULL UNIQUE,
53
+ max_uses INTEGER DEFAULT 1,
54
+ uses INTEGER DEFAULT 0,
55
+ created_by INTEGER,
56
+ expires_at TIMESTAMP,
57
+ created_at TIMESTAMP,
58
+ updated_at TIMESTAMP
59
+ )
60
+ `);
61
+ await schema.raw(`
62
+ CREATE TABLE IF NOT EXISTS email_allowlist (
63
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
64
+ email VARCHAR(255) NOT NULL UNIQUE,
65
+ created_by INTEGER,
66
+ created_at TIMESTAMP,
67
+ updated_at TIMESTAMP
68
+ )
69
+ `);
70
+ },
71
+ async down(schema) {
72
+ await schema.dropTable("email_allowlist").catch(() => { });
73
+ await schema.dropTable("invite_codes").catch(() => { });
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,10 @@
1
+ import { PackageProvider } from "../core/package.js";
2
+ /**
3
+ * Signup gates — invite codes + email allowlist.
4
+ *
5
+ * app.register(GatesServiceProvider)
6
+ */
7
+ export declare class GatesServiceProvider extends PackageProvider {
8
+ readonly name = "gates";
9
+ register(): void;
10
+ }
@@ -0,0 +1,13 @@
1
+ import { PackageProvider } from "../core/package.js";
2
+ import { gatesMigration } from "./models.js";
3
+ /**
4
+ * Signup gates — invite codes + email allowlist.
5
+ *
6
+ * app.register(GatesServiceProvider)
7
+ */
8
+ export class GatesServiceProvider extends PackageProvider {
9
+ name = "gates";
10
+ register() {
11
+ this.migrations([gatesMigration()]);
12
+ }
13
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Cloudflare REST client — D1 + Workers Custom Domains.
3
+ * Credentials are constructor args (no app config coupling).
4
+ */
5
+ export type CloudflareCredentials = {
6
+ accountId: string;
7
+ apiToken: string;
8
+ /** Optional pin for a primary zone name (e.g. sites.example.com). */
9
+ pinnedZoneId?: string;
10
+ pinnedZoneName?: string;
11
+ };
12
+ export type WorkerDomain = {
13
+ id: string;
14
+ hostname: string;
15
+ service: string;
16
+ zone_id?: string;
17
+ zone_name?: string;
18
+ };
19
+ export declare function cloudflareConfigured(creds: Partial<CloudflareCredentials>): boolean;
20
+ export declare class CloudflareClient {
21
+ private creds;
22
+ private baseUrl;
23
+ constructor(creds: CloudflareCredentials);
24
+ private accountId;
25
+ private apiToken;
26
+ private request;
27
+ createD1Database(name: string): Promise<{
28
+ uuid: string;
29
+ name: string;
30
+ }>;
31
+ resolveZoneId(zoneName: string): Promise<{
32
+ id: string;
33
+ name: string;
34
+ } | null>;
35
+ attachWorkerDomain(input: {
36
+ hostname: string;
37
+ service: string;
38
+ zoneId: string;
39
+ zoneName: string;
40
+ }): Promise<WorkerDomain>;
41
+ detachWorkerDomain(domainId: string): Promise<void>;
42
+ findWorkerDomain(hostname: string): Promise<WorkerDomain | null>;
43
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Cloudflare REST client — D1 + Workers Custom Domains.
3
+ * Credentials are constructor args (no app config coupling).
4
+ */
5
+ export function cloudflareConfigured(creds) {
6
+ return Boolean(creds.accountId && creds.apiToken);
7
+ }
8
+ export class CloudflareClient {
9
+ creds;
10
+ baseUrl = "https://api.cloudflare.com/client/v4";
11
+ constructor(creds) {
12
+ this.creds = creds;
13
+ }
14
+ accountId() {
15
+ return this.creds.accountId;
16
+ }
17
+ apiToken() {
18
+ return this.creds.apiToken;
19
+ }
20
+ async request(method, path, body) {
21
+ const response = await fetch(`${this.baseUrl}${path}`, {
22
+ method,
23
+ headers: {
24
+ authorization: `Bearer ${this.apiToken()}`,
25
+ "content-type": "application/json",
26
+ },
27
+ body: body === undefined ? undefined : JSON.stringify(body),
28
+ });
29
+ const payload = (await response.json());
30
+ if (!response.ok || !payload.success) {
31
+ const message = payload.errors?.map((e) => `${e.code}: ${e.message}`).join("; ") ??
32
+ `Cloudflare request failed (${response.status})`;
33
+ throw new Error(message);
34
+ }
35
+ return payload.result;
36
+ }
37
+ async createD1Database(name) {
38
+ return this.request("POST", `/accounts/${this.accountId()}/d1/database`, { name });
39
+ }
40
+ async resolveZoneId(zoneName) {
41
+ const pinned = this.creds.pinnedZoneId;
42
+ const pinnedName = this.creds.pinnedZoneName;
43
+ if (pinned && pinnedName && zoneName === pinnedName) {
44
+ return { id: pinned, name: zoneName };
45
+ }
46
+ const result = await this.request("GET", `/zones?name=${encodeURIComponent(zoneName)}&status=active&per_page=1`);
47
+ const zone = result?.[0];
48
+ return zone ? { id: zone.id, name: zone.name } : null;
49
+ }
50
+ async attachWorkerDomain(input) {
51
+ return this.request("PUT", `/accounts/${this.accountId()}/workers/domains`, {
52
+ hostname: input.hostname,
53
+ service: input.service,
54
+ environment: "production",
55
+ zone_id: input.zoneId,
56
+ zone_name: input.zoneName,
57
+ });
58
+ }
59
+ async detachWorkerDomain(domainId) {
60
+ await this.request("DELETE", `/accounts/${this.accountId()}/workers/domains/${domainId}`);
61
+ }
62
+ async findWorkerDomain(hostname) {
63
+ const result = await this.request("GET", `/accounts/${this.accountId()}/workers/domains?hostname=${encodeURIComponent(hostname)}`);
64
+ return result?.[0] ?? null;
65
+ }
66
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Dump a SQLite-compatible connection to a portable .sql script.
3
+ */
4
+ import type { Connection } from "../core/database.js";
5
+ export declare function dumpConnection(conn: Connection, header: string, opts?: {
6
+ generatedBy?: string;
7
+ }): Promise<string>;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Dump a SQLite-compatible connection to a portable .sql script.
3
+ */
4
+ function sqlLiteral(value) {
5
+ if (value === null || value === undefined)
6
+ return "NULL";
7
+ if (typeof value === "number" && Number.isFinite(value))
8
+ return String(value);
9
+ if (typeof value === "bigint")
10
+ return String(value);
11
+ if (typeof value === "boolean")
12
+ return value ? "1" : "0";
13
+ if (value instanceof Uint8Array) {
14
+ const hex = Array.from(value, (b) => b.toString(16).padStart(2, "0")).join("");
15
+ return `X'${hex}'`;
16
+ }
17
+ const str = String(value);
18
+ return `'${str.replace(/'/g, "''")}'`;
19
+ }
20
+ function quoteIdent(name) {
21
+ return `"${name.replace(/"/g, '""')}"`;
22
+ }
23
+ export async function dumpConnection(conn, header, opts = {}) {
24
+ const by = opts.generatedBy ?? "Keel";
25
+ const lines = [
26
+ `-- ${header}`,
27
+ `-- Generated by ${by} at ${new Date().toISOString()}`,
28
+ "PRAGMA foreign_keys=OFF;",
29
+ "BEGIN TRANSACTION;",
30
+ "",
31
+ ];
32
+ const objects = await conn.select("SELECT type, name, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 ELSE 2 END, name", []);
33
+ const tables = objects.filter((o) => o.type === "table" && typeof o.name === "string");
34
+ for (const table of tables) {
35
+ const name = String(table.name);
36
+ if (table.sql) {
37
+ lines.push(`DROP TABLE IF EXISTS ${quoteIdent(name)};`);
38
+ lines.push(`${String(table.sql)};`);
39
+ lines.push("");
40
+ }
41
+ const rows = await conn.select(`SELECT * FROM ${quoteIdent(name)}`, []);
42
+ if (rows.length === 0)
43
+ continue;
44
+ const columns = Object.keys(rows[0]);
45
+ for (const row of rows) {
46
+ const values = columns.map((col) => sqlLiteral(row[col]));
47
+ lines.push(`INSERT INTO ${quoteIdent(name)} (${columns.map(quoteIdent).join(", ")}) VALUES (${values.join(", ")});`);
48
+ }
49
+ lines.push("");
50
+ }
51
+ const indexes = objects.filter((o) => o.type === "index" && o.sql);
52
+ for (const index of indexes) {
53
+ lines.push(`${String(index.sql)};`);
54
+ }
55
+ lines.push("COMMIT;");
56
+ lines.push("");
57
+ return lines.join("\n");
58
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Hostname helpers for custom domains / Workers Custom Domains.
3
+ */
4
+ export declare function normalizeHostname(raw: string): string;
5
+ export declare function isValidHostname(hostname: string): boolean;
6
+ /** Zone name candidates from most specific to apex (e.g. app.ex.com → app.ex.com, ex.com). */
7
+ export declare function zoneCandidates(hostname: string): string[];
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Hostname helpers for custom domains / Workers Custom Domains.
3
+ */
4
+ const HOSTNAME_RE = /^(?=.{1,253}$)(?!-)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.(?!-)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i;
5
+ export function normalizeHostname(raw) {
6
+ return raw.trim().toLowerCase().replace(/\.$/, "").replace(/^https?:\/\//, "").split("/")[0];
7
+ }
8
+ export function isValidHostname(hostname) {
9
+ if (!HOSTNAME_RE.test(hostname))
10
+ return false;
11
+ if (hostname.includes(".."))
12
+ return false;
13
+ return true;
14
+ }
15
+ /** Zone name candidates from most specific to apex (e.g. app.ex.com → app.ex.com, ex.com). */
16
+ export function zoneCandidates(hostname) {
17
+ const parts = hostname.split(".").filter(Boolean);
18
+ if (parts.length < 2)
19
+ return [];
20
+ const out = [];
21
+ for (let i = 0; i <= parts.length - 2; i++) {
22
+ out.push(parts.slice(i).join("."));
23
+ }
24
+ return out;
25
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Keel Hosting — Cloudflare client, hostname helpers, SQL dump, secrets vault.
3
+ *
4
+ * import { CloudflareClient, dumpConnection, normalizeHostname } from "@shaferllc/keel/hosting";
5
+ *
6
+ * This is infrastructure for hosted Workers/D1 apps — not a full control plane.
7
+ * Product orchestration (sites, plans, deploy loops) stays in the app.
8
+ */
9
+ export { CloudflareClient, cloudflareConfigured, } from "./cloudflare.js";
10
+ export type { CloudflareCredentials, WorkerDomain } from "./cloudflare.js";
11
+ export { normalizeHostname, isValidHostname, zoneCandidates } from "./hostname.js";
12
+ export { dumpConnection } from "./dump.js";
13
+ export { normalizeSecretKey, encryptSecretValue, decryptSecretValue, resolveSecretRows, } from "./secrets.js";
14
+ export type { SecretRow } from "./secrets.js";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Keel Hosting — Cloudflare client, hostname helpers, SQL dump, secrets vault.
3
+ *
4
+ * import { CloudflareClient, dumpConnection, normalizeHostname } from "@shaferllc/keel/hosting";
5
+ *
6
+ * This is infrastructure for hosted Workers/D1 apps — not a full control plane.
7
+ * Product orchestration (sites, plans, deploy loops) stays in the app.
8
+ */
9
+ export { CloudflareClient, cloudflareConfigured, } from "./cloudflare.js";
10
+ export { normalizeHostname, isValidHostname, zoneCandidates } from "./hostname.js";
11
+ export { dumpConnection } from "./dump.js";
12
+ export { normalizeSecretKey, encryptSecretValue, decryptSecretValue, resolveSecretRows, } from "./secrets.js";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Encrypted key/value secrets vault — purpose-scoped encryption via Keel core.
3
+ *
4
+ * Apps supply a table of rows with `owner_id`, `key`, `value_encrypted`.
5
+ * This helper doesn't own the model; it encrypts/decrypts values.
6
+ */
7
+ export type SecretRow = {
8
+ key: string;
9
+ value_encrypted: string;
10
+ };
11
+ export declare function normalizeSecretKey(key: string): string;
12
+ export declare function encryptSecretValue(value: string, purpose?: string): Promise<string>;
13
+ export declare function decryptSecretValue(payload: string, purpose?: string): Promise<string | null>;
14
+ /** Decrypt a list of encrypted rows into a plain key→value map. */
15
+ export declare function resolveSecretRows(rows: SecretRow[], purpose?: string): Promise<Record<string, string>>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Encrypted key/value secrets vault — purpose-scoped encryption via Keel core.
3
+ *
4
+ * Apps supply a table of rows with `owner_id`, `key`, `value_encrypted`.
5
+ * This helper doesn't own the model; it encrypts/decrypts values.
6
+ */
7
+ import { encryption } from "../core/crypto.js";
8
+ export function normalizeSecretKey(key) {
9
+ return key.trim().toUpperCase().replace(/[^A-Z0-9_]/g, "_");
10
+ }
11
+ export async function encryptSecretValue(value, purpose = "app-secret") {
12
+ return encryption.encrypt(value, { purpose });
13
+ }
14
+ export async function decryptSecretValue(payload, purpose = "app-secret") {
15
+ const value = await encryption.decrypt(payload, { purpose });
16
+ return typeof value === "string" ? value : null;
17
+ }
18
+ /** Decrypt a list of encrypted rows into a plain key→value map. */
19
+ export async function resolveSecretRows(rows, purpose = "app-secret") {
20
+ const out = {};
21
+ for (const row of rows) {
22
+ const value = await decryptSecretValue(row.value_encrypted, purpose);
23
+ if (value !== null)
24
+ out[row.key] = value;
25
+ }
26
+ return out;
27
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.83.1",
3
+ "version": "0.83.3",
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",
@@ -159,6 +159,13 @@
159
159
  "path": "docs/factories.md",
160
160
  "example": "docs/examples/factories.ts"
161
161
  },
162
+ {
163
+ "slug": "gates",
164
+ "title": "Gates",
165
+ "summary": "Keel Gates is a signup gate for private alpha / waitlist apps: an email allowlist, invite codes with use limits and expiry, and a single check that answers \"may this person register?\".",
166
+ "path": "docs/gates.md",
167
+ "example": "docs/examples/gates.ts"
168
+ },
162
169
  {
163
170
  "slug": "getting-started",
164
171
  "title": "Getting Started",
@@ -192,7 +199,7 @@
192
199
  "title": "Built on Hono",
193
200
  "summary": "Keel's HTTP layer is Hono — an ultrafast, web-standard router that runs on Node, Cloudflare Workers, Deno, Bun, and more.",
194
201
  "path": "docs/hono.md",
195
- "example": null
202
+ "example": "docs/examples/hono.ts"
196
203
  },
197
204
  {
198
205
  "slug": "hooks",
@@ -269,21 +276,21 @@
269
276
  "title": "OpenAPI",
270
277
  "summary": "Keel OpenAPI generates an OpenAPI 3 spec from your routes and serves Swagger UI to explore it.",
271
278
  "path": "docs/openapi.md",
272
- "example": null
279
+ "example": "docs/examples/openapi.ts"
273
280
  },
274
281
  {
275
282
  "slug": "orm",
276
283
  "title": "ORM",
277
284
  "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
285
  "path": "docs/orm.md",
279
- "example": null
286
+ "example": "docs/examples/orm.ts"
280
287
  },
281
288
  {
282
289
  "slug": "packages",
283
290
  "title": "Packages",
284
291
  "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
292
  "path": "docs/packages.md",
286
- "example": null
293
+ "example": "docs/examples/packages.ts"
287
294
  },
288
295
  {
289
296
  "slug": "pages",
@@ -304,7 +311,7 @@
304
311
  "title": "Query Builder",
305
312
  "summary": "Keel's driver-agnostic query builder — build and run SQL by chaining methods off db(table).",
306
313
  "path": "docs/query-builder.md",
307
- "example": null
314
+ "example": "docs/examples/query-builder.ts"
308
315
  },
309
316
  {
310
317
  "slug": "queues",
@@ -353,7 +360,7 @@
353
360
  "title": "Securing SSR apps",
354
361
  "summary": "Two middlewares harden server-rendered apps: securityHeaders() sets the defensive HTTP headers browsers act on, and csrf() blocks cross-site form submissions.",
355
362
  "path": "docs/security.md",
356
- "example": null
363
+ "example": "docs/examples/security.ts"
357
364
  },
358
365
  {
359
366
  "slug": "sessions",
@@ -367,14 +374,14 @@
367
374
  "title": "Social authentication",
368
375
  "summary": "\"Sign in with GitHub / Google / Discord\" — OAuth 2.0, without an SDK.",
369
376
  "path": "docs/social-auth.md",
370
- "example": null
377
+ "example": "docs/examples/social-auth.ts"
371
378
  },
372
379
  {
373
380
  "slug": "starter-kits",
374
381
  "title": "Starter kits",
375
382
  "summary": "",
376
383
  "path": "docs/starter-kits.md",
377
- "example": null
384
+ "example": "docs/examples/starter-kits.ts"
378
385
  },
379
386
  {
380
387
  "slug": "static-files",
@@ -456,9 +463,9 @@
456
463
  {
457
464
  "slug": "watch",
458
465
  "title": "Watch",
459
- "summary": "Keel Watch is a debug dashboard — a Telescope for Keel.",
466
+ "summary": "Keel Watch is a debug dashboard for Keel apps.",
460
467
  "path": "docs/watch.md",
461
- "example": null
468
+ "example": "docs/examples/watch.ts"
462
469
  }
463
470
  ],
464
471
  "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 |