@shaferllc/keel 0.80.0 → 0.81.1

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 (65) hide show
  1. package/dist/accounts/accounts.config.stub +50 -0
  2. package/dist/accounts/config.d.ts +46 -0
  3. package/dist/accounts/config.js +39 -0
  4. package/dist/accounts/flows.d.ts +50 -0
  5. package/dist/accounts/flows.js +133 -0
  6. package/dist/accounts/index.d.ts +28 -0
  7. package/dist/accounts/index.js +23 -0
  8. package/dist/accounts/migration.d.ts +14 -0
  9. package/dist/accounts/migration.js +39 -0
  10. package/dist/accounts/provider.d.ts +18 -0
  11. package/dist/accounts/provider.js +37 -0
  12. package/dist/accounts/routes.d.ts +15 -0
  13. package/dist/accounts/routes.js +116 -0
  14. package/dist/accounts/store.d.ts +33 -0
  15. package/dist/accounts/store.js +37 -0
  16. package/dist/accounts/tokens.d.ts +60 -0
  17. package/dist/accounts/tokens.js +116 -0
  18. package/dist/accounts/totp.d.ts +58 -0
  19. package/dist/accounts/totp.js +134 -0
  20. package/dist/accounts/two-factor.d.ts +56 -0
  21. package/dist/accounts/two-factor.js +146 -0
  22. package/dist/core/database.d.ts +36 -0
  23. package/dist/core/database.js +141 -4
  24. package/dist/core/index.d.ts +5 -2
  25. package/dist/core/index.js +3 -2
  26. package/dist/core/migrations.d.ts +52 -2
  27. package/dist/core/migrations.js +134 -3
  28. package/dist/core/model-events.d.ts +34 -0
  29. package/dist/core/model-events.js +89 -0
  30. package/dist/core/model-query.d.ts +68 -0
  31. package/dist/core/model-query.js +234 -0
  32. package/dist/core/model.d.ts +109 -4
  33. package/dist/core/model.js +263 -32
  34. package/dist/core/relations.d.ts +53 -0
  35. package/dist/core/relations.js +242 -0
  36. package/dist/teams/config.d.ts +27 -0
  37. package/dist/teams/config.js +23 -0
  38. package/dist/teams/context.d.ts +54 -0
  39. package/dist/teams/context.js +73 -0
  40. package/dist/teams/index.d.ts +25 -0
  41. package/dist/teams/index.js +20 -0
  42. package/dist/teams/invitations.d.ts +38 -0
  43. package/dist/teams/invitations.js +123 -0
  44. package/dist/teams/middleware.d.ts +30 -0
  45. package/dist/teams/middleware.js +92 -0
  46. package/dist/teams/migration.d.ts +9 -0
  47. package/dist/teams/migration.js +52 -0
  48. package/dist/teams/models.d.ts +54 -0
  49. package/dist/teams/models.js +85 -0
  50. package/dist/teams/provider.d.ts +17 -0
  51. package/dist/teams/provider.js +27 -0
  52. package/dist/teams/teams.config.stub +24 -0
  53. package/dist/teams/tenant.d.ts +25 -0
  54. package/dist/teams/tenant.js +45 -0
  55. package/docs/accounts.md +214 -0
  56. package/docs/ai-manifest.json +70 -1
  57. package/docs/database.md +80 -0
  58. package/docs/examples/accounts.ts +150 -0
  59. package/docs/examples/teams.ts +101 -0
  60. package/docs/migrations.md +86 -6
  61. package/docs/models.md +279 -6
  62. package/docs/teams.md +176 -0
  63. package/llms-full.txt +849 -12
  64. package/llms.txt +4 -0
  65. package/package.json +10 -2
@@ -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.80.0",
3
+ "version": "0.81.1",
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",
@@ -355,6 +362,13 @@
355
362
  "path": "docs/storage.md",
356
363
  "example": "docs/examples/storage.ts"
357
364
  },
365
+ {
366
+ "slug": "teams",
367
+ "title": "Teams",
368
+ "summary": "Multi-tenancy, membership, roles, and invitations — where a row belongs to a team, and one team can never see another's.",
369
+ "path": "docs/teams.md",
370
+ "example": "docs/examples/teams.ts"
371
+ },
358
372
  {
359
373
  "slug": "telemetry",
360
374
  "title": "Telemetry",
@@ -475,6 +489,11 @@
475
489
  "kind": "value",
476
490
  "module": "helpers"
477
491
  },
492
+ {
493
+ "name": "AlterTableBuilder",
494
+ "kind": "value",
495
+ "module": "migrations"
496
+ },
478
497
  {
479
498
  "name": "AnyCommand",
480
499
  "kind": "type",
@@ -1380,6 +1399,11 @@
1380
1399
  "kind": "value",
1381
1400
  "module": "exceptions"
1382
1401
  },
1402
+ {
1403
+ "name": "ForeignKeyBuilder",
1404
+ "kind": "value",
1405
+ "module": "migrations"
1406
+ },
1383
1407
  {
1384
1408
  "name": "formatMessage",
1385
1409
  "kind": "value",
@@ -1445,6 +1469,11 @@
1445
1469
  "kind": "value",
1446
1470
  "module": "social"
1447
1471
  },
1472
+ {
1473
+ "name": "GlobalScope",
1474
+ "kind": "type",
1475
+ "module": "model"
1476
+ },
1448
1477
  {
1449
1478
  "name": "google",
1450
1479
  "kind": "value",
@@ -1890,11 +1919,46 @@
1890
1919
  "kind": "type",
1891
1920
  "module": "binding"
1892
1921
  },
1922
+ {
1923
+ "name": "ModelEvent",
1924
+ "kind": "type",
1925
+ "module": "model-events"
1926
+ },
1893
1927
  {
1894
1928
  "name": "ModelFactory",
1895
1929
  "kind": "value",
1896
1930
  "module": "factory"
1897
1931
  },
1932
+ {
1933
+ "name": "ModelHook",
1934
+ "kind": "type",
1935
+ "module": "model-events"
1936
+ },
1937
+ {
1938
+ "name": "ModelObserver",
1939
+ "kind": "type",
1940
+ "module": "model-events"
1941
+ },
1942
+ {
1943
+ "name": "ModelQuery",
1944
+ "kind": "value",
1945
+ "module": "model-query"
1946
+ },
1947
+ {
1948
+ "name": "MorphMany",
1949
+ "kind": "value",
1950
+ "module": "relations"
1951
+ },
1952
+ {
1953
+ "name": "MorphOne",
1954
+ "kind": "value",
1955
+ "module": "relations"
1956
+ },
1957
+ {
1958
+ "name": "MorphTo",
1959
+ "kind": "value",
1960
+ "module": "relations"
1961
+ },
1898
1962
  {
1899
1963
  "name": "namedLogger",
1900
1964
  "kind": "value",
@@ -2265,6 +2329,11 @@
2265
2329
  "kind": "type",
2266
2330
  "module": "pages"
2267
2331
  },
2332
+ {
2333
+ "name": "registerMorphType",
2334
+ "kind": "value",
2335
+ "module": "relations"
2336
+ },
2268
2337
  {
2269
2338
  "name": "Relation",
2270
2339
  "kind": "value",
package/docs/database.md CHANGED
@@ -145,6 +145,23 @@ await db("posts").latest().get(); // ORDER BY created_at DESC
145
145
  await db("posts").oldest("published_at").get();
146
146
  ```
147
147
 
148
+ Joins, grouping, and conditional/raw clauses:
149
+
150
+ ```ts
151
+ await db("posts")
152
+ .join("users", "posts.user_id", "users.id") // also leftJoin(...)
153
+ .select("posts.title", "users.name")
154
+ .get();
155
+
156
+ await db("posts").select("user_id").groupBy("user_id").having("COUNT(*)", ">", 5).get();
157
+ await db("users").distinct().select("country").pluck("country");
158
+
159
+ await db("events").whereColumn("updated_at", ">", "created_at").get();
160
+ await db("users").whereRaw("score >= ?", [10]).orderByRaw("LENGTH(name) DESC").get();
161
+
162
+ await db("users").when(search, (q, term) => q.whereLike("name", `%${term}%`)).get();
163
+ ```
164
+
148
165
  ## Aggregates, single values, and pagination
149
166
 
150
167
  ```ts
@@ -174,6 +191,22 @@ Everything is parameterized — values become bindings, never string-interpolate
174
191
  SQL — so it's injection-safe by construction. Writes return a `WriteResult`;
175
192
  `insertGetId` unwraps it to just the new id.
176
193
 
194
+ Counters, bulk upserts, and paged iteration:
195
+
196
+ ```ts
197
+ await db("posts").where("id", id).increment("views"); // += 1
198
+ await db("posts").where("id", id).decrement("stock", 3, { updated_at: now });
199
+
200
+ // Insert, updating the listed columns on a unique-key conflict (dialect-aware).
201
+ await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]);
202
+ await db("logs").insertOrIgnore({ key, value }); // skip duplicates
203
+
204
+ // Process a large table a page at a time (return false to stop early).
205
+ await db("users").orderBy("id").chunk(500, async (rows) => {
206
+ for (const row of rows) await process(row);
207
+ });
208
+ ```
209
+
177
210
  > **Guard your writes.** `update()` and `delete()` apply to every row that
178
211
  > matches the current `where` clause — with no `where`, that's the whole table.
179
212
  > Always scope a write with `where` unless you truly mean to touch every row.
@@ -576,6 +609,53 @@ await db("sessions").where("expires_at", "<", now).delete();
576
609
  **Notes:** with no `where`, empties the table. There's no soft-delete here — pair
577
610
  with a `deleted_at` column and `whereNull` if you want one.
578
611
 
612
+ #### `whereColumn(first, operator?, second)` · `whereRaw(sql, bindings?)`
613
+
614
+ Compare two columns (no binding) or add a raw WHERE fragment with its own
615
+ bindings. `whereColumn("updated_at", ">", "created_at")`; `whereRaw("score >= ?", [10])`.
616
+
617
+ #### `join(table, first, operator?, second)` · `leftJoin(...)`
618
+
619
+ Add an `INNER JOIN` / `LEFT JOIN` on an equality (or the given operator).
620
+ Included in `get`, `count`, and aggregates. Qualify ambiguous columns
621
+ (`"posts.user_id"`).
622
+
623
+ #### `groupBy(...columns)` · `having(column, operator?, value)` · `distinct()`
624
+
625
+ `GROUP BY`, a bound `HAVING` predicate, and `SELECT DISTINCT`.
626
+
627
+ #### `orderByRaw(sql)` · `when(condition, then, otherwise?)`
628
+
629
+ A raw `ORDER BY` fragment; and conditional building — `then(query, value)` runs
630
+ only when `condition` is truthy, else `otherwise`.
631
+
632
+ #### `increment(column, amount?, extra?)` · `decrement(column, amount?, extra?)`
633
+
634
+ `increment(column: string, amount = 1, extra: Row = {}): Promise<WriteResult>`
635
+
636
+ Atomically `column = column ± amount` on matching rows, optionally setting other
637
+ columns in the same statement. Scope with `where`.
638
+
639
+ #### `upsert(rows, uniqueBy, update?)`
640
+
641
+ `upsert(rows: Row | Row[], uniqueBy: string[], update?: string[]): Promise<WriteResult>`
642
+
643
+ Insert rows, updating `update` columns (default: all non-unique) on a conflict
644
+ against `uniqueBy`. Dialect-aware: `ON CONFLICT … DO UPDATE` (sqlite/postgres) or
645
+ `ON DUPLICATE KEY UPDATE` (mysql).
646
+
647
+ #### `insertOrIgnore(rows)`
648
+
649
+ Insert one or more rows, skipping any that violate a unique constraint
650
+ (`INSERT OR IGNORE` / `INSERT IGNORE` / `ON CONFLICT DO NOTHING`).
651
+
652
+ #### `chunk(size, callback)`
653
+
654
+ `chunk(size: number, callback: (rows: T[]) => void | boolean | Promise<void | boolean>): Promise<void>`
655
+
656
+ Process results a page at a time so a large table never loads at once. Return
657
+ `false` from the callback to stop early. Pair with `orderBy` for a stable order.
658
+
579
659
  ### Interfaces & types
580
660
 
581
661
  #### `Connection`
@@ -0,0 +1,150 @@
1
+ // Typechecked example for docs/accounts.md.
2
+ import { Application } from "@shaferllc/keel/core";
3
+ import {
4
+ AccountsServiceProvider,
5
+ attempt,
6
+ completeTwoFactor,
7
+ confirmTwoFactor,
8
+ disableTwoFactor,
9
+ enableTwoFactor,
10
+ hasTwoFactor,
11
+ regenerateRecoveryCodes,
12
+ requestPasswordReset,
13
+ resetPassword,
14
+ sendVerificationEmail,
15
+ verifyEmail,
16
+ accountStore,
17
+ setAccountStore,
18
+ type AccountUser,
19
+ } from "@shaferllc/keel/accounts";
20
+
21
+ const app = new Application();
22
+
23
+ /* ------------------------------- turning it on ---------------------------- */
24
+
25
+ app.register(AccountsServiceProvider);
26
+
27
+ /* ---------------------------------- login --------------------------------- */
28
+
29
+ async function login(email: string, password: string) {
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
+ return { user: result.user };
42
+ }
43
+
44
+ async function finishTwoFactor(challenge: string, code: string) {
45
+ // Takes an authenticator code or a recovery code.
46
+ const user = await completeTwoFactor(challenge, code);
47
+ if (!user) return { error: "That code isn't valid." };
48
+
49
+ return { user };
50
+ }
51
+
52
+ /* ------------------------------ password reset ---------------------------- */
53
+
54
+ async function forgot(email: string) {
55
+ await requestPasswordReset(email);
56
+ // Always the same answer, whether or not that address has an account.
57
+ return { status: "If that address has an account, a link is on its way." };
58
+ }
59
+
60
+ async function reset(token: string, password: string) {
61
+ const ok = await resetPassword(token, password);
62
+ return ok ? { status: "Password reset." } : { error: "That link is invalid or expired." };
63
+ }
64
+
65
+ /* --------------------------- email verification --------------------------- */
66
+
67
+ async function afterRegistration(user: AccountUser) {
68
+ await sendVerificationEmail(user);
69
+ }
70
+
71
+ async function confirmEmail(token: string) {
72
+ const user = await verifyEmail(token);
73
+ return user ? { status: "Confirmed." } : { error: "That link is invalid or expired." };
74
+ }
75
+
76
+ /* -------------------------------- two factor ------------------------------ */
77
+
78
+ async function startTwoFactor(user: AccountUser) {
79
+ // Step one: a secret and recovery codes — but 2FA is NOT on yet.
80
+ const setup = await enableTwoFactor(user, { issuer: "Acme" });
81
+
82
+ // Render setup.uri to a QR code locally. It contains the secret; never send it
83
+ // to a third-party QR service.
84
+ return {
85
+ uri: setup.uri,
86
+ secret: setup.secret,
87
+ recoveryCodes: setup.recoveryCodes, // shown once
88
+ };
89
+ }
90
+
91
+ async function finishSetup(user: AccountUser, code: string) {
92
+ // Step two: a working code turns it on. Without this, a bad scan locks them out.
93
+ const ok = await confirmTwoFactor(user, code);
94
+ return ok ? { status: "Two-factor is on." } : { error: "That code isn't valid." };
95
+ }
96
+
97
+ async function accountSettings(user: AccountUser) {
98
+ return {
99
+ twoFactorEnabled: hasTwoFactor(user),
100
+ };
101
+ }
102
+
103
+ async function newCodes(user: AccountUser) {
104
+ return regenerateRecoveryCodes(user); // invalidates the old set
105
+ }
106
+
107
+ async function turnOff(user: AccountUser) {
108
+ await disableTwoFactor(user);
109
+ }
110
+
111
+ /* ------------------------------ a custom store ---------------------------- */
112
+
113
+ // Users somewhere other than a `users` table? Replace the whole store. (Anything
114
+ // that can find a user and update one will do — here, a map.)
115
+ const people = new Map<string | number, AccountUser>();
116
+
117
+ setAccountStore({
118
+ async findById(id) {
119
+ return people.get(id) ?? null;
120
+ },
121
+ async findByEmail(email) {
122
+ for (const person of people.values()) {
123
+ if (person.email === email.toLowerCase()) return person;
124
+ }
125
+ return null;
126
+ },
127
+ async update(id, values) {
128
+ const person = people.get(id);
129
+ if (person) people.set(id, { ...person, ...values });
130
+ },
131
+ });
132
+
133
+ // The store the rest of the module reads through.
134
+ const store = accountStore();
135
+
136
+ export {
137
+ login,
138
+ finishTwoFactor,
139
+ forgot,
140
+ reset,
141
+ afterRegistration,
142
+ confirmEmail,
143
+ startTwoFactor,
144
+ finishSetup,
145
+ accountSettings,
146
+ newCodes,
147
+ turnOff,
148
+ app,
149
+ store,
150
+ };
@@ -0,0 +1,101 @@
1
+ // Typechecked example for docs/teams.md.
2
+ import { Application, sessionMiddleware } from "@shaferllc/keel/core";
3
+ import {
4
+ TeamsServiceProvider,
5
+ TenantModel,
6
+ TENANT_SCOPE,
7
+ acceptInvitation,
8
+ createTeam,
9
+ currentTeam,
10
+ invite,
11
+ memberOf,
12
+ pendingInvitations,
13
+ requireRole,
14
+ revokeInvitation,
15
+ roleAtLeast,
16
+ roleOf,
17
+ runForTeam,
18
+ switchTeam,
19
+ teamContext,
20
+ teamsFor,
21
+ withoutTenant,
22
+ } from "@shaferllc/keel/teams";
23
+
24
+ const app = new Application();
25
+ app.register(TeamsServiceProvider);
26
+
27
+ // Put every request inside a team; TenantModel queries are scoped from then on.
28
+ const middleware = [sessionMiddleware(), teamContext()];
29
+
30
+ /* -------------------------------- a tenant model -------------------------- */
31
+
32
+ class Post extends TenantModel {
33
+ static override table = "posts";
34
+ static override fillable = ["title"];
35
+
36
+ declare id: number;
37
+ declare title: string;
38
+ }
39
+
40
+ async function inAHandler() {
41
+ const mine = await Post.all(); // only the current team's
42
+ const one = await Post.find(1); // null if it belongs to another team
43
+ const made = await Post.create({ title: "Hi" }); // stamped with the current team
44
+ return { mine, one, made, team: currentTeam() };
45
+ }
46
+
47
+ /* ------------------------------ outside a request ------------------------- */
48
+
49
+ async function aJob(team: { id: number }) {
50
+ // Without this, the query throws — it does not quietly return every team's rows.
51
+ await runForTeam(team, async () => {
52
+ await Post.all();
53
+ });
54
+ }
55
+
56
+ async function anAdminReport() {
57
+ // Crossing tenants, said out loud so it can be found at audit time.
58
+ return withoutTenant(() => Post.withoutGlobalScope(TENANT_SCOPE).get());
59
+ }
60
+
61
+ /* --------------------------------- membership ----------------------------- */
62
+
63
+ async function onboarding(userId: number) {
64
+ const team = await createTeam("Acme", userId);
65
+
66
+ await teamsFor(userId);
67
+ await roleOf(userId, team.id);
68
+ await memberOf(userId, team.id, "admin");
69
+ await switchTeam(userId, team.id); // false if they aren't a member
70
+
71
+ return team;
72
+ }
73
+
74
+ const adminsOnly = requireRole("admin");
75
+ const ordered = roleAtLeast("owner", "admin"); // true
76
+
77
+ /* -------------------------------- invitations ----------------------------- */
78
+
79
+ async function inviteSomeone(teamId: number, userId: number) {
80
+ const { token, invitation } = await invite(teamId, "grace@example.com", "admin");
81
+
82
+ await pendingInvitations(teamId);
83
+ await revokeInvitation(invitation.id);
84
+
85
+ // The invited address is re-checked, so a forwarded link can't be redeemed by
86
+ // someone else.
87
+ return acceptInvitation(token, userId, "grace@example.com");
88
+ }
89
+
90
+ export {
91
+ app,
92
+ middleware,
93
+ Post,
94
+ inAHandler,
95
+ aJob,
96
+ anAdminReport,
97
+ onboarding,
98
+ adminsOnly,
99
+ ordered,
100
+ inviteSomeone,
101
+ };