@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.
- 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/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 +89 -0
- package/dist/core/model-query.d.ts +68 -0
- package/dist/core/model-query.js +234 -0
- package/dist/core/model.d.ts +109 -4
- package/dist/core/model.js +263 -32
- package/dist/core/relations.d.ts +53 -0
- package/dist/core/relations.js +242 -0
- package/dist/teams/config.d.ts +27 -0
- package/dist/teams/config.js +23 -0
- package/dist/teams/context.d.ts +54 -0
- package/dist/teams/context.js +73 -0
- package/dist/teams/index.d.ts +25 -0
- package/dist/teams/index.js +20 -0
- package/dist/teams/invitations.d.ts +38 -0
- package/dist/teams/invitations.js +123 -0
- package/dist/teams/middleware.d.ts +30 -0
- package/dist/teams/middleware.js +92 -0
- package/dist/teams/migration.d.ts +9 -0
- package/dist/teams/migration.js +52 -0
- package/dist/teams/models.d.ts +54 -0
- package/dist/teams/models.js +85 -0
- package/dist/teams/provider.d.ts +17 -0
- package/dist/teams/provider.js +27 -0
- package/dist/teams/teams.config.stub +24 -0
- package/dist/teams/tenant.d.ts +25 -0
- package/dist/teams/tenant.js +45 -0
- package/docs/accounts.md +214 -0
- package/docs/ai-manifest.json +70 -1
- package/docs/database.md +80 -0
- package/docs/examples/accounts.ts +150 -0
- package/docs/examples/teams.ts +101 -0
- package/docs/migrations.md +86 -6
- package/docs/models.md +279 -6
- package/docs/teams.md +176 -0
- package/llms-full.txt +849 -12
- package/llms.txt +4 -0
- package/package.json +10 -2
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.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
|
+
};
|