@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.
- package/README.md +3 -1
- package/dist/accounts/accounts.config.stub +50 -0
- package/dist/accounts/config.d.ts +46 -0
- package/dist/accounts/config.js +39 -0
- package/dist/accounts/flows.d.ts +50 -0
- package/dist/accounts/flows.js +133 -0
- package/dist/accounts/index.d.ts +28 -0
- package/dist/accounts/index.js +23 -0
- package/dist/accounts/migration.d.ts +14 -0
- package/dist/accounts/migration.js +39 -0
- package/dist/accounts/provider.d.ts +18 -0
- package/dist/accounts/provider.js +37 -0
- package/dist/accounts/routes.d.ts +15 -0
- package/dist/accounts/routes.js +116 -0
- package/dist/accounts/store.d.ts +33 -0
- package/dist/accounts/store.js +37 -0
- package/dist/accounts/tokens.d.ts +60 -0
- package/dist/accounts/tokens.js +116 -0
- package/dist/accounts/totp.d.ts +58 -0
- package/dist/accounts/totp.js +134 -0
- package/dist/accounts/two-factor.d.ts +56 -0
- package/dist/accounts/two-factor.js +146 -0
- package/dist/billing/billable.d.ts +83 -0
- package/dist/billing/billable.js +177 -0
- package/dist/billing/billing.config.stub +33 -0
- package/dist/billing/builder.d.ts +54 -0
- package/dist/billing/builder.js +104 -0
- package/dist/billing/config.d.ts +43 -0
- package/dist/billing/config.js +35 -0
- package/dist/billing/crypto.d.ts +11 -0
- package/dist/billing/crypto.js +27 -0
- package/dist/billing/drivers/fake.d.ts +58 -0
- package/dist/billing/drivers/fake.js +190 -0
- package/dist/billing/drivers/index.d.ts +11 -0
- package/dist/billing/drivers/index.js +22 -0
- package/dist/billing/drivers/paddle.d.ts +39 -0
- package/dist/billing/drivers/paddle.js +197 -0
- package/dist/billing/drivers/stripe.d.ts +33 -0
- package/dist/billing/drivers/stripe.js +278 -0
- package/dist/billing/events.d.ts +25 -0
- package/dist/billing/events.js +7 -0
- package/dist/billing/gateway.d.ts +170 -0
- package/dist/billing/gateway.js +24 -0
- package/dist/billing/index.d.ts +28 -0
- package/dist/billing/index.js +19 -0
- package/dist/billing/manager.d.ts +34 -0
- package/dist/billing/manager.js +61 -0
- package/dist/billing/migration.d.ts +13 -0
- package/dist/billing/migration.js +68 -0
- package/dist/billing/provider.d.ts +20 -0
- package/dist/billing/provider.js +42 -0
- package/dist/billing/routes.d.ts +11 -0
- package/dist/billing/routes.js +21 -0
- package/dist/billing/subscription-item.d.ts +18 -0
- package/dist/billing/subscription-item.js +11 -0
- package/dist/billing/subscription.d.ts +85 -0
- package/dist/billing/subscription.js +157 -0
- package/dist/billing/webhooks.d.ts +26 -0
- package/dist/billing/webhooks.js +75 -0
- package/dist/core/database.d.ts +36 -0
- package/dist/core/database.js +141 -4
- package/dist/core/index.d.ts +5 -2
- package/dist/core/index.js +3 -2
- package/dist/core/migrations.d.ts +52 -2
- package/dist/core/migrations.js +134 -3
- package/dist/core/model-events.d.ts +34 -0
- package/dist/core/model-events.js +71 -0
- package/dist/core/model-query.d.ts +68 -0
- package/dist/core/model-query.js +234 -0
- package/dist/core/model.d.ts +91 -4
- package/dist/core/model.js +217 -32
- package/dist/core/relations.d.ts +53 -0
- package/dist/core/relations.js +242 -0
- package/docs/accounts.md +214 -0
- package/docs/ai-manifest.json +70 -1
- package/docs/billing.md +242 -0
- package/docs/database.md +33 -0
- package/docs/examples/accounts.ts +150 -0
- package/docs/migrations.md +32 -3
- package/docs/models.md +133 -3
- package/docs/packages.md +3 -1
- package/llms-full.txt +671 -7
- package/llms.txt +3 -0
- package/package.json +10 -2
package/dist/core/relations.js
CHANGED
|
@@ -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
|
}
|
package/docs/accounts.md
ADDED
|
@@ -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 |
|
package/docs/ai-manifest.json
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shaferllc/keel",
|
|
3
|
-
"version": "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",
|