@shaferllc/keel 0.79.0 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +3 -1
  2. package/dist/accounts/accounts.config.stub +50 -0
  3. package/dist/accounts/config.d.ts +46 -0
  4. package/dist/accounts/config.js +39 -0
  5. package/dist/accounts/flows.d.ts +50 -0
  6. package/dist/accounts/flows.js +133 -0
  7. package/dist/accounts/index.d.ts +28 -0
  8. package/dist/accounts/index.js +23 -0
  9. package/dist/accounts/migration.d.ts +14 -0
  10. package/dist/accounts/migration.js +39 -0
  11. package/dist/accounts/provider.d.ts +18 -0
  12. package/dist/accounts/provider.js +37 -0
  13. package/dist/accounts/routes.d.ts +15 -0
  14. package/dist/accounts/routes.js +116 -0
  15. package/dist/accounts/store.d.ts +33 -0
  16. package/dist/accounts/store.js +37 -0
  17. package/dist/accounts/tokens.d.ts +60 -0
  18. package/dist/accounts/tokens.js +116 -0
  19. package/dist/accounts/totp.d.ts +58 -0
  20. package/dist/accounts/totp.js +134 -0
  21. package/dist/accounts/two-factor.d.ts +56 -0
  22. package/dist/accounts/two-factor.js +146 -0
  23. package/dist/billing/billable.d.ts +83 -0
  24. package/dist/billing/billable.js +177 -0
  25. package/dist/billing/billing.config.stub +33 -0
  26. package/dist/billing/builder.d.ts +54 -0
  27. package/dist/billing/builder.js +104 -0
  28. package/dist/billing/config.d.ts +43 -0
  29. package/dist/billing/config.js +35 -0
  30. package/dist/billing/crypto.d.ts +11 -0
  31. package/dist/billing/crypto.js +27 -0
  32. package/dist/billing/drivers/fake.d.ts +58 -0
  33. package/dist/billing/drivers/fake.js +190 -0
  34. package/dist/billing/drivers/index.d.ts +11 -0
  35. package/dist/billing/drivers/index.js +22 -0
  36. package/dist/billing/drivers/paddle.d.ts +39 -0
  37. package/dist/billing/drivers/paddle.js +197 -0
  38. package/dist/billing/drivers/stripe.d.ts +33 -0
  39. package/dist/billing/drivers/stripe.js +278 -0
  40. package/dist/billing/events.d.ts +25 -0
  41. package/dist/billing/events.js +7 -0
  42. package/dist/billing/gateway.d.ts +170 -0
  43. package/dist/billing/gateway.js +24 -0
  44. package/dist/billing/index.d.ts +28 -0
  45. package/dist/billing/index.js +19 -0
  46. package/dist/billing/manager.d.ts +34 -0
  47. package/dist/billing/manager.js +61 -0
  48. package/dist/billing/migration.d.ts +13 -0
  49. package/dist/billing/migration.js +68 -0
  50. package/dist/billing/provider.d.ts +20 -0
  51. package/dist/billing/provider.js +42 -0
  52. package/dist/billing/routes.d.ts +11 -0
  53. package/dist/billing/routes.js +21 -0
  54. package/dist/billing/subscription-item.d.ts +18 -0
  55. package/dist/billing/subscription-item.js +11 -0
  56. package/dist/billing/subscription.d.ts +85 -0
  57. package/dist/billing/subscription.js +157 -0
  58. package/dist/billing/webhooks.d.ts +26 -0
  59. package/dist/billing/webhooks.js +75 -0
  60. package/dist/core/database.d.ts +36 -0
  61. package/dist/core/database.js +141 -4
  62. package/dist/core/index.d.ts +5 -2
  63. package/dist/core/index.js +3 -2
  64. package/dist/core/migrations.d.ts +52 -2
  65. package/dist/core/migrations.js +134 -3
  66. package/dist/core/model-events.d.ts +34 -0
  67. package/dist/core/model-events.js +71 -0
  68. package/dist/core/model-query.d.ts +68 -0
  69. package/dist/core/model-query.js +234 -0
  70. package/dist/core/model.d.ts +91 -4
  71. package/dist/core/model.js +217 -32
  72. package/dist/core/relations.d.ts +53 -0
  73. package/dist/core/relations.js +242 -0
  74. package/docs/accounts.md +214 -0
  75. package/docs/ai-manifest.json +70 -1
  76. package/docs/billing.md +242 -0
  77. package/docs/database.md +33 -0
  78. package/docs/examples/accounts.ts +150 -0
  79. package/docs/migrations.md +32 -3
  80. package/docs/models.md +133 -3
  81. package/docs/packages.md +3 -1
  82. package/llms-full.txt +671 -7
  83. package/llms.txt +3 -0
  84. package/package.json +10 -2
@@ -28,6 +28,13 @@ import { db } from "./database.js";
28
28
  function unique(values) {
29
29
  return [...new Set(values)];
30
30
  }
31
+ /** Count how many times each value appears — the basis of `withCount`. */
32
+ function tally(values) {
33
+ const counts = new Map();
34
+ for (const value of values)
35
+ counts.set(value, (counts.get(value) ?? 0) + 1);
36
+ return counts;
37
+ }
31
38
  /** Base class: a relationship is awaitable (resolves to its loaded result). */
32
39
  export class Relation {
33
40
  parent;
@@ -76,6 +83,21 @@ export class HasMany extends Relation {
76
83
  m.setRelation(name, grouped.get(m[this.localKey]) ?? []);
77
84
  }
78
85
  }
86
+ parentColumn() {
87
+ return this.localKey;
88
+ }
89
+ async matchingParentKeys(constrain) {
90
+ const q = db(this.related.table, this.related.connection);
91
+ constrain?.(q);
92
+ return unique((await q.pluck(this.foreignKey)).filter((v) => v != null));
93
+ }
94
+ async countsByParent(parentKeys) {
95
+ return tally(parentKeys.length
96
+ ? await db(this.related.table, this.related.connection)
97
+ .whereIn(this.foreignKey, parentKeys)
98
+ .pluck(this.foreignKey)
99
+ : []);
100
+ }
79
101
  }
80
102
  /* --------------------------------- has-one --------------------------------- */
81
103
  export class HasOne extends Relation {
@@ -110,6 +132,21 @@ export class HasOne extends Relation {
110
132
  m.setRelation(name, byKey.get(m[this.localKey]) ?? null);
111
133
  }
112
134
  }
135
+ parentColumn() {
136
+ return this.localKey;
137
+ }
138
+ async matchingParentKeys(constrain) {
139
+ const q = db(this.related.table, this.related.connection);
140
+ constrain?.(q);
141
+ return unique((await q.pluck(this.foreignKey)).filter((v) => v != null));
142
+ }
143
+ async countsByParent(parentKeys) {
144
+ return tally(parentKeys.length
145
+ ? await db(this.related.table, this.related.connection)
146
+ .whereIn(this.foreignKey, parentKeys)
147
+ .pluck(this.foreignKey)
148
+ : []);
149
+ }
113
150
  }
114
151
  /* -------------------------------- belongs-to ------------------------------- */
115
152
  export class BelongsTo extends Relation {
@@ -144,6 +181,25 @@ export class BelongsTo extends Relation {
144
181
  m.setRelation(name, byKey.get(m[this.foreignKey]) ?? null);
145
182
  }
146
183
  }
184
+ parentColumn() {
185
+ return this.foreignKey;
186
+ }
187
+ async matchingParentKeys(constrain) {
188
+ const q = db(this.related.table, this.related.connection);
189
+ constrain?.(q);
190
+ return unique((await q.pluck(this.ownerKey)).filter((v) => v != null));
191
+ }
192
+ async countsByParent(parentKeys) {
193
+ const existing = new Set(parentKeys.length
194
+ ? await db(this.related.table, this.related.connection)
195
+ .whereIn(this.ownerKey, parentKeys)
196
+ .pluck(this.ownerKey)
197
+ : []);
198
+ const counts = new Map();
199
+ for (const key of parentKeys)
200
+ counts.set(key, existing.has(key) ? 1 : 0);
201
+ return counts;
202
+ }
147
203
  }
148
204
  /* ----------------------------- belongs-to-many ----------------------------- */
149
205
  export class BelongsToMany extends Relation {
@@ -226,4 +282,190 @@ export class BelongsToMany extends Relation {
226
282
  for (const id of ids)
227
283
  await this.attach(id);
228
284
  }
285
+ parentColumn() {
286
+ return this.parentKey;
287
+ }
288
+ async matchingParentKeys(constrain) {
289
+ const rq = db(this.related.table, this.related.connection);
290
+ constrain?.(rq);
291
+ const relatedIds = unique((await rq.pluck(this.relatedKey)).filter((v) => v != null));
292
+ if (!relatedIds.length)
293
+ return [];
294
+ const pivots = await db(this.pivotTable, this.related.connection)
295
+ .whereIn(this.relatedPivotKey, relatedIds)
296
+ .pluck(this.foreignPivotKey);
297
+ return unique(pivots.filter((v) => v != null));
298
+ }
299
+ async countsByParent(parentKeys) {
300
+ return tally(parentKeys.length
301
+ ? await db(this.pivotTable, this.related.connection)
302
+ .whereIn(this.foreignPivotKey, parentKeys)
303
+ .pluck(this.foreignPivotKey)
304
+ : []);
305
+ }
306
+ }
307
+ /* ----------------------------- polymorphic --------------------------------- */
308
+ /** Maps a stored morph-type string to its model class, so `morphTo` can resolve it. */
309
+ const morphRegistry = new Map();
310
+ /** Register a model under a morph type string (usually its class name). */
311
+ export function registerMorphType(type, related) {
312
+ morphRegistry.set(type, related);
313
+ }
314
+ /** The parent side of a polymorphic one-to-many (`Post.comments()` over `commentable`). */
315
+ export class MorphMany extends Relation {
316
+ morphType;
317
+ idColumn;
318
+ typeColumn;
319
+ localKey;
320
+ constructor(parent, related, morphType, idColumn, typeColumn, localKey) {
321
+ super(parent, related);
322
+ this.morphType = morphType;
323
+ this.idColumn = idColumn;
324
+ this.typeColumn = typeColumn;
325
+ this.localKey = localKey;
326
+ }
327
+ localValue() {
328
+ return this.parent[this.localKey];
329
+ }
330
+ query() {
331
+ return db(this.related.table, this.related.connection)
332
+ .where(this.typeColumn, this.morphType)
333
+ .where(this.idColumn, this.localValue());
334
+ }
335
+ async get() {
336
+ return this.hydrate(await this.query().get());
337
+ }
338
+ async eager(models, name) {
339
+ const keys = unique(models.map((m) => m[this.localKey]).filter((v) => v != null));
340
+ const rows = keys.length
341
+ ? await db(this.related.table, this.related.connection)
342
+ .where(this.typeColumn, this.morphType)
343
+ .whereIn(this.idColumn, keys)
344
+ .get()
345
+ : [];
346
+ const grouped = new Map();
347
+ for (const row of rows) {
348
+ const bucket = grouped.get(row[this.idColumn]) ?? [];
349
+ bucket.push(new this.related(row));
350
+ grouped.set(row[this.idColumn], bucket);
351
+ }
352
+ for (const m of models)
353
+ m.setRelation(name, grouped.get(m[this.localKey]) ?? []);
354
+ }
355
+ parentColumn() {
356
+ return this.localKey;
357
+ }
358
+ async matchingParentKeys(constrain) {
359
+ const q = db(this.related.table, this.related.connection).where(this.typeColumn, this.morphType);
360
+ constrain?.(q);
361
+ return unique((await q.pluck(this.idColumn)).filter((v) => v != null));
362
+ }
363
+ async countsByParent(parentKeys) {
364
+ return tally(parentKeys.length
365
+ ? await db(this.related.table, this.related.connection)
366
+ .where(this.typeColumn, this.morphType)
367
+ .whereIn(this.idColumn, parentKeys)
368
+ .pluck(this.idColumn)
369
+ : []);
370
+ }
371
+ /** Create a related row with the morph keys (`*_id` / `*_type`) filled in. */
372
+ create(attributes) {
373
+ return this.related.create({
374
+ ...attributes,
375
+ [this.idColumn]: this.localValue(),
376
+ [this.typeColumn]: this.morphType,
377
+ });
378
+ }
379
+ }
380
+ /** The parent side of a polymorphic one-to-one. */
381
+ export class MorphOne extends Relation {
382
+ morphType;
383
+ idColumn;
384
+ typeColumn;
385
+ localKey;
386
+ constructor(parent, related, morphType, idColumn, typeColumn, localKey) {
387
+ super(parent, related);
388
+ this.morphType = morphType;
389
+ this.idColumn = idColumn;
390
+ this.typeColumn = typeColumn;
391
+ this.localKey = localKey;
392
+ }
393
+ query() {
394
+ return db(this.related.table, this.related.connection)
395
+ .where(this.typeColumn, this.morphType)
396
+ .where(this.idColumn, this.parent[this.localKey]);
397
+ }
398
+ async get() {
399
+ const row = await this.query().first();
400
+ return row ? new this.related(row) : null;
401
+ }
402
+ async eager(models, name) {
403
+ const keys = unique(models.map((m) => m[this.localKey]).filter((v) => v != null));
404
+ const rows = keys.length
405
+ ? await db(this.related.table, this.related.connection)
406
+ .where(this.typeColumn, this.morphType)
407
+ .whereIn(this.idColumn, keys)
408
+ .get()
409
+ : [];
410
+ const byKey = new Map();
411
+ for (const row of rows)
412
+ if (!byKey.has(row[this.idColumn]))
413
+ byKey.set(row[this.idColumn], new this.related(row));
414
+ for (const m of models)
415
+ m.setRelation(name, byKey.get(m[this.localKey]) ?? null);
416
+ }
417
+ }
418
+ /** The owning side of a polymorphic relation — resolves its parent by stored type + id. */
419
+ export class MorphTo {
420
+ parent;
421
+ idColumn;
422
+ typeColumn;
423
+ constructor(parent, idColumn, typeColumn) {
424
+ this.parent = parent;
425
+ this.idColumn = idColumn;
426
+ this.typeColumn = typeColumn;
427
+ }
428
+ relatedClass() {
429
+ const type = this.parent[this.typeColumn];
430
+ return type ? morphRegistry.get(type) : undefined;
431
+ }
432
+ async get() {
433
+ const cls = this.relatedClass();
434
+ const id = this.parent[this.idColumn];
435
+ if (!cls || id == null)
436
+ return null;
437
+ const row = await db(cls.table, cls.connection).where(cls.primaryKey, id).first();
438
+ return row ? new cls(row) : null;
439
+ }
440
+ async eager(models, name) {
441
+ const byType = new Map();
442
+ for (const m of models) {
443
+ const type = m[this.typeColumn];
444
+ if (!type) {
445
+ m.setRelation(name, null);
446
+ continue;
447
+ }
448
+ const group = byType.get(type) ?? [];
449
+ group.push(m);
450
+ byType.set(type, group);
451
+ }
452
+ for (const [type, group] of byType) {
453
+ const cls = morphRegistry.get(type);
454
+ if (!cls) {
455
+ for (const m of group)
456
+ m.setRelation(name, null);
457
+ continue;
458
+ }
459
+ const ids = unique(group.map((m) => m[this.idColumn]).filter((v) => v != null));
460
+ const rows = ids.length ? await db(cls.table, cls.connection).whereIn(cls.primaryKey, ids).get() : [];
461
+ const byId = new Map(rows.map((row) => [row[cls.primaryKey], row]));
462
+ for (const m of group) {
463
+ const row = byId.get(m[this.idColumn]);
464
+ m.setRelation(name, row ? new cls(row) : null);
465
+ }
466
+ }
467
+ }
468
+ then(onFulfilled, onRejected) {
469
+ return this.get().then(onFulfilled, onRejected);
470
+ }
229
471
  }
@@ -0,0 +1,214 @@
1
+ # Accounts
2
+
3
+ Password reset, email verification, and two-factor authentication — the flows every
4
+ app with a login needs, built on primitives already in core (`hash`, `encryption`,
5
+ `mail`, `rate-limit`).
6
+
7
+ They live in the framework, tested once, rather than being copy-pasted into each new
8
+ app. A password-reset flow written five times is four copies that quietly rot.
9
+
10
+ ```bash
11
+ npm install @shaferllc/keel
12
+ ```
13
+
14
+ ```ts
15
+ // bootstrap/providers.ts
16
+ import { AccountsServiceProvider } from "@shaferllc/keel/accounts";
17
+
18
+ app.register(AccountsServiceProvider);
19
+ ```
20
+
21
+ That merges config, adds four columns to your `users` table via a migration, and
22
+ mounts the JSON endpoints. **Views stay yours** — these are functions and JSON
23
+ endpoints; your controllers render the forms.
24
+
25
+ ## Login
26
+
27
+ `attempt()` checks a password. What comes back depends on whether the user has 2FA.
28
+
29
+ ```ts
30
+ const result = await attempt(email, password);
31
+
32
+ if (result.status === "failed") {
33
+ return { error: "Those credentials don't match." };
34
+ }
35
+
36
+ if (result.status === "two-factor") {
37
+ // Nothing is logged in yet. Hold the challenge, ask for a code.
38
+ return { twoFactor: true, challenge: result.challenge };
39
+ }
40
+
41
+ auth().login(result.user.id);
42
+ ```
43
+
44
+ A wrong email and a wrong password give the same answer, and take the same time —
45
+ `attempt()` hashes against `hash.dummy` when no user is found, because a fast "no
46
+ such user" tells an attacker which addresses are registered.
47
+
48
+ ## Two-factor
49
+
50
+ ### The challenge is not a session
51
+
52
+ When 2FA is on, a correct password yields a **challenge**, not a login. Nothing is
53
+ authenticated until the code verifies.
54
+
55
+ This matters more than it looks. The usual implementation logs the user in and sets a
56
+ `needs_2fa` flag for middleware to check — which means they are holding a real
57
+ authenticated session *before* the second factor. Every route that forgets the
58
+ middleware, and every `auth()` call that only asks "is anyone logged in?", is then
59
+ bypassable with just a password. The second factor becomes advisory.
60
+
61
+ Here there is no half-authenticated state to forget about, because there is no
62
+ session. The challenge is a short-lived token bound to a single purpose, so it cannot
63
+ be swapped for a session cookie or spent as a password-reset link.
64
+
65
+ ```ts
66
+ const user = await completeTwoFactor(challenge, code);
67
+ if (!user) return { error: "That code isn't valid." };
68
+
69
+ auth().login(user.id);
70
+ ```
71
+
72
+ `completeTwoFactor()` accepts an authenticator code **or** a recovery code.
73
+
74
+ ### Turning it on takes two steps
75
+
76
+ ```ts
77
+ // Step one: a secret and recovery codes. 2FA is NOT on yet.
78
+ const setup = await enableTwoFactor(user, { issuer: "Acme" });
79
+ ```
80
+
81
+ `setup.uri` is an `otpauth://` URI to render as a QR code. `setup.secret` is for
82
+ manual entry. `setup.recoveryCodes` are shown **once**.
83
+
84
+ > Render the QR **locally**. The URI contains the shared secret, so posting it to a
85
+ > QR-image service hands your users' second factor to a third party.
86
+
87
+ ```ts
88
+ // Step two: a working code turns it on.
89
+ const ok = await confirmTwoFactor(user, code);
90
+ ```
91
+
92
+ The two-step dance is deliberate. A one-step "enable" locks out every user who scans
93
+ the QR wrong or whose phone clock is off — and what's broken is the very thing they'd
94
+ need to get back in. Until `confirmTwoFactor()` succeeds, `hasTwoFactor()` is false
95
+ and login works as before.
96
+
97
+ ### Recovery codes
98
+
99
+ Eight by default, hashed at rest, single-use — redeeming one burns it, so a code read
100
+ over your shoulder is one you have already spent.
101
+
102
+ ```ts
103
+ await recoveryCodesRemaining(user); // worth surfacing when it gets low
104
+ await regenerateRecoveryCodes(user); // invalidates the old set
105
+ await disableTwoFactor(user); // destroys the secret and the codes
106
+ ```
107
+
108
+ ### What's stored
109
+
110
+ The TOTP secret is **encrypted** at rest and the recovery codes are **hashed**, so a
111
+ leaked database hands over neither the second factor nor the backdoor.
112
+
113
+ TOTP itself is RFC 6238, verified against the RFC's published test vectors, and built
114
+ on WebCrypto with no dependencies — so it runs unchanged on the edge.
115
+
116
+ ## Password reset
117
+
118
+ ```ts
119
+ await requestPasswordReset(email); // emails a link, or quietly does nothing
120
+ ```
121
+
122
+ The answer is the same whether or not that address has an account. "No account with
123
+ that address" is a free enumeration oracle on an unauthenticated endpoint that anyone
124
+ can ask about anyone.
125
+
126
+ ```ts
127
+ const ok = await resetPassword(token, password);
128
+ ```
129
+
130
+ **A reset link works exactly once**, and there is no `password_resets` table. The
131
+ token carries its own purpose and expiry inside the ciphertext, and it is bound to a
132
+ fingerprint of the current password hash — so the moment the password changes, every
133
+ token minted against the old one is dead. Nothing to store, nothing to clean up, and
134
+ no window where a stale row is still redeemable because a cron job didn't run.
135
+
136
+ ## Email verification
137
+
138
+ ```ts
139
+ await sendVerificationEmail(user);
140
+ const user = await verifyEmail(token); // idempotent
141
+ ```
142
+
143
+ The token is bound to the address it was sent to. A link mailed to an old address
144
+ cannot verify a new one — otherwise changing your email to someone else's and clicking
145
+ an older link would mark *their* address as proven.
146
+
147
+ ## The endpoints
148
+
149
+ Mounted at `auth` unless you turn them off (`routes.enabled: false`) and call the
150
+ functions from your own controllers instead.
151
+
152
+ | Method | Path | Notes |
153
+ | --- | --- | --- |
154
+ | POST | `/auth/login` | `{ email, password }` → a user, or `{ twoFactor, challenge }` |
155
+ | POST | `/auth/two-factor` | `{ challenge, code }` — code or recovery code |
156
+ | POST | `/auth/password/forgot` | Always `202`. Never says who exists. |
157
+ | POST | `/auth/password/reset` | `{ token, password }` |
158
+ | POST | `/auth/email/verify` | `{ token }` |
159
+ | POST | `/auth/email/resend` | Always `202` |
160
+
161
+ Every one is unauthenticated and touches credentials, so the group is rate-limited
162
+ (5 per minute by default). Without a throttle, a six-digit code inside a 30-second
163
+ window is guessable, and forgot-password is an email cannon pointed at whoever the
164
+ caller names.
165
+
166
+ ## Configuration
167
+
168
+ ```bash
169
+ keel vendor:publish --tag accounts-config
170
+ ```
171
+
172
+ ```ts
173
+ export default {
174
+ userTable: "users",
175
+ routes: { enabled: true, prefix: "auth" },
176
+ passwordReset: { expiresIn: "60m", url: "/reset-password?token=:token" },
177
+ verification: { expiresIn: "24h", url: "/verify-email?token=:token" },
178
+ twoFactor: {
179
+ issuer: env("APP_NAME", "Keel"),
180
+ window: 1, // ±30s of clock drift
181
+ challengeExpiresIn: "5m",
182
+ recoveryCodes: 8,
183
+ },
184
+ rateLimit: { max: 5, window: 60 },
185
+ };
186
+ ```
187
+
188
+ `twoFactor.challengeExpiresIn` is the window in which a stolen password alone is
189
+ enough. Keep it short.
190
+
191
+ ## A different users table
192
+
193
+ Accounts talks to a table through the query builder rather than assuming a `Model`.
194
+ If your users live somewhere else — an auth service, a legacy schema — replace the
195
+ store:
196
+
197
+ ```ts
198
+ setAccountStore({
199
+ async findById(id) { /* … */ },
200
+ async findByEmail(email) { /* … */ },
201
+ async update(id, values) { /* … */ },
202
+ });
203
+ ```
204
+
205
+ ## The schema
206
+
207
+ Four columns on your users table, and no tokens table:
208
+
209
+ | Column | |
210
+ | --- | --- |
211
+ | `email_verified_at` | null until proven |
212
+ | `two_factor_secret` | encrypted at rest |
213
+ | `two_factor_recovery_codes` | hashed, then encrypted |
214
+ | `two_factor_confirmed_at` | null until a working code proves it |
@@ -1,10 +1,17 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.79.0",
3
+ "version": "0.81.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",
7
7
  "docs": [
8
+ {
9
+ "slug": "accounts",
10
+ "title": "Accounts",
11
+ "summary": "Password reset, email verification, and two-factor authentication — the flows every app with a login needs, built on primitives already in core (hash, encryption, mail, rate-limit).",
12
+ "path": "docs/accounts.md",
13
+ "example": "docs/examples/accounts.ts"
14
+ },
8
15
  {
9
16
  "slug": "ai",
10
17
  "title": "Building Keel apps with AI",
@@ -40,6 +47,13 @@
40
47
  "path": "docs/authorization.md",
41
48
  "example": "docs/examples/authorization.ts"
42
49
  },
50
+ {
51
+ "slug": "billing",
52
+ "title": "Billing",
53
+ "summary": "Keel Billing is a subscription-billing layer — a port of Laravel Cashier — for charging customers, managing subscriptions, and reconciling gateway state through webhooks.",
54
+ "path": "docs/billing.md",
55
+ "example": null
56
+ },
43
57
  {
44
58
  "slug": "broadcasting",
45
59
  "title": "Broadcasting",
@@ -468,6 +482,11 @@
468
482
  "kind": "value",
469
483
  "module": "helpers"
470
484
  },
485
+ {
486
+ "name": "AlterTableBuilder",
487
+ "kind": "value",
488
+ "module": "migrations"
489
+ },
471
490
  {
472
491
  "name": "AnyCommand",
473
492
  "kind": "type",
@@ -1373,6 +1392,11 @@
1373
1392
  "kind": "value",
1374
1393
  "module": "exceptions"
1375
1394
  },
1395
+ {
1396
+ "name": "ForeignKeyBuilder",
1397
+ "kind": "value",
1398
+ "module": "migrations"
1399
+ },
1376
1400
  {
1377
1401
  "name": "formatMessage",
1378
1402
  "kind": "value",
@@ -1438,6 +1462,11 @@
1438
1462
  "kind": "value",
1439
1463
  "module": "social"
1440
1464
  },
1465
+ {
1466
+ "name": "GlobalScope",
1467
+ "kind": "type",
1468
+ "module": "model"
1469
+ },
1441
1470
  {
1442
1471
  "name": "google",
1443
1472
  "kind": "value",
@@ -1883,11 +1912,46 @@
1883
1912
  "kind": "type",
1884
1913
  "module": "binding"
1885
1914
  },
1915
+ {
1916
+ "name": "ModelEvent",
1917
+ "kind": "type",
1918
+ "module": "model-events"
1919
+ },
1886
1920
  {
1887
1921
  "name": "ModelFactory",
1888
1922
  "kind": "value",
1889
1923
  "module": "factory"
1890
1924
  },
1925
+ {
1926
+ "name": "ModelHook",
1927
+ "kind": "type",
1928
+ "module": "model-events"
1929
+ },
1930
+ {
1931
+ "name": "ModelObserver",
1932
+ "kind": "type",
1933
+ "module": "model-events"
1934
+ },
1935
+ {
1936
+ "name": "ModelQuery",
1937
+ "kind": "value",
1938
+ "module": "model-query"
1939
+ },
1940
+ {
1941
+ "name": "MorphMany",
1942
+ "kind": "value",
1943
+ "module": "relations"
1944
+ },
1945
+ {
1946
+ "name": "MorphOne",
1947
+ "kind": "value",
1948
+ "module": "relations"
1949
+ },
1950
+ {
1951
+ "name": "MorphTo",
1952
+ "kind": "value",
1953
+ "module": "relations"
1954
+ },
1891
1955
  {
1892
1956
  "name": "namedLogger",
1893
1957
  "kind": "value",
@@ -2258,6 +2322,11 @@
2258
2322
  "kind": "type",
2259
2323
  "module": "pages"
2260
2324
  },
2325
+ {
2326
+ "name": "registerMorphType",
2327
+ "kind": "value",
2328
+ "module": "relations"
2329
+ },
2261
2330
  {
2262
2331
  "name": "Relation",
2263
2332
  "kind": "value",