@oimlsmart/platform-server 0.1.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 (48) hide show
  1. package/README.md +92 -0
  2. package/migrations/0001_init.sql +69 -0
  3. package/migrations/0002_identity.sql +24 -0
  4. package/migrations/0003_federation_peers.sql +21 -0
  5. package/migrations/0003_users_rbac.sql +8 -0
  6. package/migrations/0004_oidc_op.sql +61 -0
  7. package/migrations/0005_upstream_providers.sql +34 -0
  8. package/migrations/0006_op_accounts.sql +28 -0
  9. package/migrations/0007_org_join_requests.sql +26 -0
  10. package/migrations/0008_op_client_roles.sql +19 -0
  11. package/migrations/0009_account_console.sql +32 -0
  12. package/migrations/0009_sso_states.sql +13 -0
  13. package/migrations/0010_notify_events.sql +23 -0
  14. package/migrations/0011_op_launch.sql +18 -0
  15. package/migrations/0011_org_memberships.sql +62 -0
  16. package/migrations/0012_notify_subscriptions.sql +57 -0
  17. package/migrations/0012_strong_auth.sql +109 -0
  18. package/migrations/0013_org_registry.sql +53 -0
  19. package/migrations/0014_notify_inbox.sql +34 -0
  20. package/migrations/0015_certificate_holder_attribution.sql +63 -0
  21. package/migrations/0016_instrument_registrations.sql +78 -0
  22. package/package.json +52 -0
  23. package/src/client-info.ts +25 -0
  24. package/src/context.ts +31 -0
  25. package/src/github.ts +284 -0
  26. package/src/mailer.ts +309 -0
  27. package/src/oidc.ts +369 -0
  28. package/src/profile/node.ts +83 -0
  29. package/src/profile.ts +582 -0
  30. package/src/rbac/node.ts +42 -0
  31. package/src/rbac.ts +53 -0
  32. package/src/session.ts +45 -0
  33. package/src/store/d1.ts +2850 -0
  34. package/src/store/sqlite/entities.ts +71 -0
  35. package/src/store/sqlite/events.ts +82 -0
  36. package/src/store/sqlite/factors-store.ts +348 -0
  37. package/src/store/sqlite/notify.ts +247 -0
  38. package/src/store/sqlite/op-accounts-store.ts +470 -0
  39. package/src/store/sqlite/op-store.ts +280 -0
  40. package/src/store/sqlite/schema.sql +745 -0
  41. package/src/store/sqlite/store.ts +1390 -0
  42. package/src/store/sqlite/upstream-store.ts +148 -0
  43. package/src/store/sqlite.ts +1027 -0
  44. package/src/store.ts +1826 -0
  45. package/src/vocab/index.ts +12 -0
  46. package/src/vocab/permissions.ts +398 -0
  47. package/src/vocab/rbac.ts +281 -0
  48. package/src/vocab/roles.ts +162 -0
@@ -0,0 +1,470 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // The OP account model's SQLite half (TODO.identity/02) — the sync
3
+ // implementations behind the ServerStore account/enrollment/session
4
+ // methods (sqlite-server-store.ts delegates here one-for-one, mirroring
5
+ // op-store.ts's role for the OIDC state and upstream-store.ts's for the
6
+ // linked identities, TODO.identity/08).
7
+ //
8
+ // NODE-ONLY: better-sqlite3 through ./store's getDb. The Worker bundle
9
+ // never sees this module (the D1 store implements the same surface in
10
+ // d1-store.ts).
11
+ // ═══════════════════════════════════════════════════════════════════
12
+
13
+ import { randomUUID } from 'crypto'
14
+ import { getDb } from './store'
15
+ import type {
16
+ CompleteEmailChangeResult,
17
+ CompleteEnrollmentResult,
18
+ EmailChangeToken,
19
+ EnrollmentToken,
20
+ OpClientRoleAssignment,
21
+ OpLiveSession,
22
+ SessionView,
23
+ UserAdminRow,
24
+ } from '../../store'
25
+
26
+ function toEnrollmentToken(row: Record<string, unknown>): EnrollmentToken {
27
+ return {
28
+ token: row.token as string,
29
+ userId: row.user_id as string,
30
+ createdBy: (row.created_by as string | null) ?? null,
31
+ createdAt: row.created_at as string,
32
+ expiresAt: row.expires_at as string,
33
+ consumedAt: (row.consumed_at as string | null) ?? null,
34
+ }
35
+ }
36
+
37
+ /** Create the OP password account. Answers null when the email is taken
38
+ * (the invite route's 409; the UNIQUE constraint is the race backstop). */
39
+ export function createOpAccount(input: {
40
+ email: string
41
+ name: string
42
+ role: string
43
+ createdBy?: string | null
44
+ }): UserAdminRow | null {
45
+ const db = getDb()
46
+ const id = randomUUID()
47
+ try {
48
+ db.prepare(
49
+ "INSERT INTO users (id, email, name, provider, role) VALUES (?, ?, ?, 'password', ?)",
50
+ ).run(id, input.email.trim().toLowerCase(), input.name.trim(), input.role)
51
+ } catch (e) {
52
+ if (String((e as Error).message).includes('UNIQUE')) return null
53
+ throw e
54
+ }
55
+ const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id) as Record<string, unknown>
56
+ return {
57
+ id: row.id as string,
58
+ email: row.email as string,
59
+ name: row.name as string,
60
+ role: row.role as string,
61
+ roles: [row.role as string],
62
+ orgId: (row.org_id as string | null) ?? null,
63
+ active: row.active !== 0,
64
+ provider: row.provider as string,
65
+ lastLogin: (row.last_login as string | null) ?? null,
66
+ }
67
+ }
68
+
69
+ /** The password sign-in's lookup: the credential + the active flag, by
70
+ * (normalized) email. The credential's EXISTENCE is the qualifier — an
71
+ * account that holds a password may sign in with it. */
72
+ export function getPasswordLogin(email: string): { userId: string; hash: string; active: boolean } | null {
73
+ const row = getDb().prepare(
74
+ `SELECT u.id AS user_id, u.active AS active, p.hash AS hash
75
+ FROM users u JOIN passwords p ON p.user_id = u.id
76
+ WHERE u.email = ?`,
77
+ ).get(email.trim().toLowerCase()) as Record<string, unknown> | undefined
78
+ if (!row) return null
79
+ return { userId: row.user_id as string, hash: row.hash as string, active: row.active !== 0 }
80
+ }
81
+
82
+ export function setPasswordHash(userId: string, hash: string, setBy?: string | null): void {
83
+ getDb().prepare(
84
+ `INSERT INTO passwords (user_id, hash, set_by) VALUES (?, ?, ?)
85
+ ON CONFLICT (user_id) DO UPDATE SET hash = excluded.hash, set_at = datetime('now'), set_by = excluded.set_by`,
86
+ ).run(userId, hash, setBy ?? null)
87
+ }
88
+
89
+ /** The sign-in methods the account holds (the account page's
90
+ * password-set state + the admin list's posture). TODO.identity-sso/02:
91
+ * the passkeys count — a passkey is a PRIMARY sign-in method
92
+ * (passwordless), so the at-least-one-way-in guard reads it. */
93
+ export function countSignInMethods(userId: string): { password: boolean; links: number; passkeys: number } {
94
+ const db = getDb()
95
+ const pw = db.prepare('SELECT COUNT(*) AS n FROM passwords WHERE user_id = ?').get(userId) as { n: number }
96
+ const links = db.prepare('SELECT COUNT(*) AS n FROM identity_links WHERE user_id = ?').get(userId) as { n: number }
97
+ const passkeys = db.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?').get(userId) as { n: number }
98
+ return { password: pw.n > 0, links: links.n, passkeys: passkeys.n }
99
+ }
100
+
101
+ export function createEnrollmentToken(input: {
102
+ token: string
103
+ userId: string
104
+ createdBy?: string | null
105
+ ttlMs: number
106
+ }): EnrollmentToken {
107
+ const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
108
+ getDb().prepare(
109
+ 'INSERT INTO enrollment_tokens (token, user_id, created_by, expires_at) VALUES (?, ?, ?, ?)',
110
+ ).run(input.token, input.userId, input.createdBy ?? null, expiresAt)
111
+ return getEnrollmentToken(input.token)!
112
+ }
113
+
114
+ export function getEnrollmentToken(token: string): EnrollmentToken | null {
115
+ const row = getDb().prepare('SELECT * FROM enrollment_tokens WHERE token = ?').get(token) as Record<string, unknown> | undefined
116
+ return row ? toEnrollmentToken(row) : null
117
+ }
118
+
119
+ /** Complete the enrollment: the token is consumed ATOMICALLY first (a
120
+ * presented link works exactly once — a concurrent double-submit loses
121
+ * the race), then the expiry is judged (an expired link is burned, never
122
+ * redeemed later), then the password lands. */
123
+ export function completeEnrollment(token: string, passwordHash: string, setBy?: string | null): CompleteEnrollmentResult {
124
+ const db = getDb()
125
+ const res = db.prepare(
126
+ "UPDATE enrollment_tokens SET consumed_at = datetime('now') WHERE token = ? AND consumed_at IS NULL",
127
+ ).run(token)
128
+ if (res.changes === 0) return { kind: 'unknown' }
129
+ const row = getEnrollmentToken(token)!
130
+ if (new Date(row.expiresAt).getTime() <= Date.now()) return { kind: 'expired' }
131
+ setPasswordHash(row.userId, passwordHash, setBy)
132
+ // TODO.identity/06: the invite ceremony doubles as the address's
133
+ // verification (the administrator delivered the one-time link to that
134
+ // mailbox out-of-band; completing it proves the pair).
135
+ db.prepare("UPDATE users SET email_verified_at = datetime('now') WHERE id = ?").run(row.userId)
136
+ return { kind: 'ok', userId: row.userId }
137
+ }
138
+
139
+ /** The account's live sessions, `current` computed in SQL against the
140
+ * presenting token — the token value itself never leaves the store. */
141
+ export function listUserSessions(userId: string, currentToken?: string): SessionView[] {
142
+ const rows = getDb().prepare(
143
+ `SELECT id, created_at, expires_at, last_seen_at, user_agent, ip, (token = ?) AS is_current
144
+ FROM sessions
145
+ WHERE user_id = ? AND expires_at > datetime('now')
146
+ ORDER BY created_at DESC`,
147
+ ).all(currentToken ?? '', userId) as Array<Record<string, unknown>>
148
+ return rows.map(row => ({
149
+ id: row.id as string,
150
+ createdAt: row.created_at as string,
151
+ expiresAt: row.expires_at as string,
152
+ lastSeenAt: (row.last_seen_at as string | null) ?? null,
153
+ userAgent: (row.user_agent as string | null) ?? null,
154
+ ip: (row.ip as string | null) ?? null,
155
+ current: Number(row.is_current) === 1,
156
+ }))
157
+ }
158
+
159
+ /** Revoke ONE of the account's own sessions (the user_id clause makes
160
+ * another account's session id a no-op). */
161
+ export function deleteSessionById(userId: string, sessionId: string): boolean {
162
+ const res = getDb().prepare('DELETE FROM sessions WHERE id = ? AND user_id = ?').run(sessionId, userId)
163
+ return res.changes > 0
164
+ }
165
+
166
+ /** Every live session across accounts (expired excluded), `current`
167
+ * computed in SQL against the presenting token — the aggregate admin
168
+ * read (TODO.identity-sso/01); the token never leaves the store. */
169
+ export function listOpLiveSessions(currentToken?: string): OpLiveSession[] {
170
+ const rows = getDb().prepare(
171
+ `SELECT id, user_id, created_at, expires_at, last_seen_at, user_agent, ip, (token = ?) AS is_current
172
+ FROM sessions
173
+ WHERE expires_at > datetime('now')
174
+ ORDER BY created_at DESC`,
175
+ ).all(currentToken ?? '') as Array<Record<string, unknown>>
176
+ return rows.map(row => ({
177
+ id: row.id as string,
178
+ userId: row.user_id as string,
179
+ createdAt: row.created_at as string,
180
+ expiresAt: row.expires_at as string,
181
+ lastSeenAt: (row.last_seen_at as string | null) ?? null,
182
+ userAgent: (row.user_agent as string | null) ?? null,
183
+ ip: (row.ip as string | null) ?? null,
184
+ current: Number(row.is_current) === 1,
185
+ }))
186
+ }
187
+
188
+ /** The administrator's revoke-all (TODO.identity-sso/01's light act):
189
+ * every session of the account, none kept; answers the count. */
190
+ export function deleteAllUserSessions(userId: string): number {
191
+ return getDb().prepare('DELETE FROM sessions WHERE user_id = ?').run(userId).changes
192
+ }
193
+
194
+ // ── the central user registry (TODO.identity/03) ─────────────────────
195
+ // The per-client role assignments + the deactivation's revocation half
196
+ // + the audit-chain sign-in read.
197
+
198
+ interface OpClientRoleRow {
199
+ user_id: string
200
+ client_id: string
201
+ roles: string
202
+ assigned_by: string | null
203
+ created_at: string
204
+ updated_at: string | null
205
+ }
206
+
207
+ function toClientRoleAssignment(row: OpClientRoleRow): OpClientRoleAssignment {
208
+ return {
209
+ userId: row.user_id,
210
+ clientId: row.client_id,
211
+ roles: JSON.parse(row.roles) as string[],
212
+ assignedBy: row.assigned_by ?? null,
213
+ createdAt: row.created_at,
214
+ updatedAt: row.updated_at ?? null,
215
+ }
216
+ }
217
+
218
+ export function listOpClientRoles(userId: string): OpClientRoleAssignment[] {
219
+ const rows = getDb().prepare(
220
+ 'SELECT * FROM op_client_roles WHERE user_id = ? ORDER BY client_id',
221
+ ).all(userId) as unknown as OpClientRoleRow[]
222
+ return rows.map(toClientRoleAssignment)
223
+ }
224
+
225
+ /** EVERY per-client assignment across accounts (TODO.identity-sso/01's
226
+ * live access review). */
227
+ export function listAllOpClientRoles(): OpClientRoleAssignment[] {
228
+ const rows = getDb().prepare(
229
+ 'SELECT * FROM op_client_roles ORDER BY user_id, client_id',
230
+ ).all() as unknown as OpClientRoleRow[]
231
+ return rows.map(toClientRoleAssignment)
232
+ }
233
+
234
+ /** The assignment for ONE client: NULL = no row (the account's OP-side
235
+ * role set is that client's default); an EMPTY array = the explicit
236
+ * "no roles on this client". */
237
+ export function getOpClientRoles(userId: string, clientId: string): string[] | null {
238
+ const row = getDb().prepare(
239
+ 'SELECT roles FROM op_client_roles WHERE user_id = ? AND client_id = ?',
240
+ ).get(userId, clientId) as { roles: string } | undefined
241
+ return row ? (JSON.parse(row.roles) as string[]) : null
242
+ }
243
+
244
+ export function setOpClientRoles(userId: string, clientId: string, roles: string[], assignedBy: string | null): void {
245
+ getDb().prepare(`
246
+ INSERT INTO op_client_roles (user_id, client_id, roles, assigned_by)
247
+ VALUES (?, ?, ?, ?)
248
+ ON CONFLICT (user_id, client_id) DO UPDATE SET
249
+ roles = excluded.roles,
250
+ assigned_by = excluded.assigned_by,
251
+ updated_at = datetime('now')
252
+ `).run(userId, clientId, JSON.stringify(roles), assignedBy)
253
+ }
254
+
255
+ export function deleteOpClientRoles(userId: string, clientId: string): boolean {
256
+ const res = getDb().prepare('DELETE FROM op_client_roles WHERE user_id = ? AND client_id = ?').run(userId, clientId)
257
+ return res.changes > 0
258
+ }
259
+
260
+ /** The deactivation's revocation half: every live session, every issued
261
+ * access token, every unconsumed code and pending authorization goes.
262
+ * The user row STAYS (the history). */
263
+ export function revokeOpUserCredentials(userId: string): { sessions: number; accessTokens: number; codes: number; authorizations: number } {
264
+ const db = getDb()
265
+ const sessions = db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId).changes
266
+ const accessTokens = db.prepare('DELETE FROM oidc_access_tokens WHERE user_id = ?').run(userId).changes
267
+ const codes = db.prepare('DELETE FROM oidc_codes WHERE user_id = ? AND consumed_at IS NULL').run(userId).changes
268
+ const authorizations = db.prepare('DELETE FROM oidc_authorizations WHERE user_id = ? AND decision IS NULL').run(userId).changes
269
+ return { sessions, accessTokens, codes, authorizations }
270
+ }
271
+
272
+ /** The registry's edit act (name/email). The email UNIQUE conflict
273
+ * throws 'unique' (the route maps it to a 409, never a silent take).
274
+ * TODO.identity/06: an admin-set address never went through the
275
+ * verify-new-email ceremony, so the verification state resets. */
276
+ export function updateOpAccount(id: string, input: { name?: string; email?: string }): boolean {
277
+ const db = getDb()
278
+ if (input.email !== undefined) {
279
+ try {
280
+ db.prepare('UPDATE users SET email = ?, email_verified_at = NULL WHERE id = ?').run(input.email.trim().toLowerCase(), id)
281
+ } catch (e) {
282
+ if (String((e as Error).message).includes('UNIQUE')) throw new Error(`unique: ${input.email}`)
283
+ throw e
284
+ }
285
+ }
286
+ if (input.name !== undefined) {
287
+ db.prepare('UPDATE users SET name = ? WHERE id = ?').run(input.name.trim(), id)
288
+ }
289
+ const res = db.prepare('SELECT 1 AS ok FROM users WHERE id = ?').get(id)
290
+ return !!res
291
+ }
292
+
293
+ /** The registry's ERASURE act (the offboarding runbook's delete path —
294
+ * docs/deployment/identity-operations.md): everything the account held
295
+ * is removed (the password credential, the enrollment + email-change
296
+ * tokens, the linked identities, the per-client role assignments, every
297
+ * live credential) and the user row is ANONYMIZED in place: the row
298
+ * survives as a tombstone (the audit chain's entity_id still resolves,
299
+ * foreign keys never dangle) but carries no person — name, email,
300
+ * organization, roles, avatar and the verification stamp all go, and
301
+ * provider becomes 'erased' so the row drops out of every account
302
+ * surface (the registry list, the sign-in joins, the admin acts).
303
+ * Answers the removal counts (the audit event's metadata), or null when
304
+ * the account does not exist. */
305
+ export function eraseOpAccount(userId: string): {
306
+ sessions: number
307
+ accessTokens: number
308
+ codes: number
309
+ authorizations: number
310
+ links: number
311
+ clientRoles: number
312
+ memberships: number
313
+ tokens: number
314
+ factors: number
315
+ } | null {
316
+ const db = getDb()
317
+ const row = db.prepare('SELECT 1 AS ok FROM users WHERE id = ?').get(userId)
318
+ if (!row) return null
319
+ const revoked = revokeOpUserCredentials(userId)
320
+ const links = db.prepare('DELETE FROM identity_links WHERE user_id = ?').run(userId).changes
321
+ const clientRoles = db.prepare('DELETE FROM op_client_roles WHERE user_id = ?').run(userId).changes
322
+ // TODO.identity/11: the memberships go too (every org's row — the
323
+ // tombstone acts for no organization).
324
+ const memberships = db.prepare('DELETE FROM org_memberships WHERE user_id = ?').run(userId).changes
325
+ const tokens =
326
+ db.prepare('DELETE FROM passwords WHERE user_id = ?').run(userId).changes +
327
+ db.prepare('DELETE FROM enrollment_tokens WHERE user_id = ?').run(userId).changes +
328
+ db.prepare('DELETE FROM email_change_tokens WHERE user_id = ?').run(userId).changes
329
+ // TODO.identity-sso/02+03: the factor registry follows the account
330
+ // into erasure — passkeys, TOTP secrets, recovery codes, and the
331
+ // pending ceremony state (challenges, pending-MFA rows).
332
+ const factors =
333
+ db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(userId).changes +
334
+ db.prepare('DELETE FROM totp_secrets WHERE user_id = ?').run(userId).changes +
335
+ db.prepare('DELETE FROM recovery_codes WHERE user_id = ?').run(userId).changes +
336
+ db.prepare('DELETE FROM webauthn_challenges WHERE user_id = ?').run(userId).changes +
337
+ db.prepare('DELETE FROM mfa_pending WHERE user_id = ?').run(userId).changes
338
+ db.prepare(
339
+ `UPDATE users SET
340
+ email = ?, name = 'Deleted account', provider = 'erased',
341
+ role = 'viewer', roles = NULL, org_id = NULL,
342
+ avatar_url = NULL, email_verified_at = NULL, active = 0
343
+ WHERE id = ?`,
344
+ ).run(`deleted-${userId}@erased.invalid`, userId)
345
+ return { ...revoked, links, clientRoles, memberships, tokens, factors }
346
+ }
347
+
348
+ /** The last OP-side sign-in per account, FROM THE AUDIT CHAIN: the
349
+ * newest auditEvents row whose action is a sign-in ('account.sign_in'
350
+ * — the password login; 'upstream_sign_in' — a linked-provider
351
+ * sign-in) per entity_id (the account id). */
352
+ export function lastAccountSignIns(): Record<string, string> {
353
+ const rows = getDb().prepare(
354
+ `SELECT data FROM entities
355
+ WHERE store = 'auditEvents'
356
+ AND (data LIKE '%"action":"account.sign_in"%' OR data LIKE '%"action":"upstream_sign_in"%')`,
357
+ ).all() as Array<{ data: string }>
358
+ const out: Record<string, string> = {}
359
+ for (const { data } of rows) {
360
+ try {
361
+ const event = JSON.parse(data) as { entity_id?: string; timestamp?: string }
362
+ if (typeof event.entity_id !== 'string' || typeof event.timestamp !== 'string') continue
363
+ if (!out[event.entity_id] || event.timestamp > out[event.entity_id]!) {
364
+ out[event.entity_id] = event.timestamp
365
+ }
366
+ } catch { /* a malformed audit row is skipped, never trusted */ }
367
+ }
368
+ return out
369
+ }
370
+
371
+ // ── the account console (TODO.identity/06) ───────────────────────────
372
+
373
+ /** The profile edit's write (the display name). */
374
+ export function updateUserName(userId: string, name: string): boolean {
375
+ const res = getDb().prepare('UPDATE users SET name = ? WHERE id = ?').run(name.trim(), userId)
376
+ return res.changes > 0
377
+ }
378
+
379
+ /** The avatar write (the account console's upload/remove): users.avatar_url
380
+ * carries the serving URL (the OP's own avatar route for an upload, the
381
+ * linked provider's picture URL for an OAuth-provisioned row), NULL when
382
+ * the account shows its initials. Answers false when the account is gone. */
383
+ export function setUserAvatar(userId: string, avatarUrl: string | null): boolean {
384
+ const res = getDb().prepare('UPDATE users SET avatar_url = ? WHERE id = ?').run(avatarUrl, userId)
385
+ return res.changes > 0
386
+ }
387
+
388
+ /** Remove the account's password credential (the route holds the
389
+ * at-least-one-method guard). */
390
+ export function deletePasswordHash(userId: string): boolean {
391
+ const res = getDb().prepare('DELETE FROM passwords WHERE user_id = ?').run(userId)
392
+ return res.changes > 0
393
+ }
394
+
395
+ /** Revoke every session of the account EXCEPT the presenting one.
396
+ * Answers the revoked count. */
397
+ export function deleteOtherSessions(userId: string, keepToken: string): number {
398
+ const res = getDb().prepare('DELETE FROM sessions WHERE user_id = ? AND token != ?').run(userId, keepToken)
399
+ return res.changes
400
+ }
401
+
402
+ function toEmailChangeToken(row: Record<string, unknown>): EmailChangeToken {
403
+ return {
404
+ token: row.token as string,
405
+ userId: row.user_id as string,
406
+ newEmail: row.new_email as string,
407
+ deliveredBy: row.delivered_by === 'mailer' ? 'mailer' : 'shown',
408
+ createdAt: row.created_at as string,
409
+ expiresAt: row.expires_at as string,
410
+ consumedAt: (row.consumed_at as string | null) ?? null,
411
+ }
412
+ }
413
+
414
+ /** Mint the ceremony's token; the account's earlier pending rows are
415
+ * VOIDED first (only the newest link works). */
416
+ export function createEmailChangeToken(input: {
417
+ token: string
418
+ userId: string
419
+ newEmail: string
420
+ deliveredBy: 'mailer' | 'shown'
421
+ ttlMs: number
422
+ }): EmailChangeToken {
423
+ const db = getDb()
424
+ db.prepare(
425
+ "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND consumed_at IS NULL",
426
+ ).run(input.userId)
427
+ const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
428
+ db.prepare(
429
+ 'INSERT INTO email_change_tokens (token, user_id, new_email, delivered_by, expires_at) VALUES (?, ?, ?, ?, ?)',
430
+ ).run(input.token, input.userId, input.newEmail.trim().toLowerCase(), input.deliveredBy, expiresAt)
431
+ return getEmailChangeToken(input.token)!
432
+ }
433
+
434
+ export function getEmailChangeToken(token: string): EmailChangeToken | null {
435
+ const row = getDb().prepare('SELECT * FROM email_change_tokens WHERE token = ?').get(token) as Record<string, unknown> | undefined
436
+ return row ? toEmailChangeToken(row) : null
437
+ }
438
+
439
+ /** The account's pending change (the newest live row), so the console
440
+ * can show it. */
441
+ export function getPendingEmailChange(userId: string): EmailChangeToken | null {
442
+ const row = getDb().prepare(
443
+ `SELECT * FROM email_change_tokens
444
+ WHERE user_id = ? AND consumed_at IS NULL AND expires_at > datetime('now')
445
+ ORDER BY created_at DESC LIMIT 1`,
446
+ ).get(userId) as Record<string, unknown> | undefined
447
+ return row ? toEmailChangeToken(row) : null
448
+ }
449
+
450
+ /** Complete the ceremony: consume ATOMICALLY (a presented link works
451
+ * exactly once, expired or not), judge the expiry, re-check the
452
+ * address's uniqueness (a conflict burns the token honestly), then move
453
+ * the account's email. A 'mailer'-delivered token verifies the address;
454
+ * a shown one applies the change with the address staying unverified. */
455
+ export function completeEmailChange(token: string): CompleteEmailChangeResult {
456
+ const db = getDb()
457
+ const res = db.prepare(
458
+ "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE token = ? AND consumed_at IS NULL",
459
+ ).run(token)
460
+ if (res.changes === 0) return { kind: 'unknown' }
461
+ const row = getEmailChangeToken(token)!
462
+ if (new Date(row.expiresAt).getTime() <= Date.now()) return { kind: 'expired' }
463
+ const taken = db.prepare('SELECT id FROM users WHERE email = ?').get(row.newEmail) as { id: string } | undefined
464
+ if (taken) return { kind: 'conflict' }
465
+ const verified = row.deliveredBy === 'mailer'
466
+ db.prepare(
467
+ `UPDATE users SET email = ?, email_verified_at = ${verified ? "datetime('now')" : 'NULL'} WHERE id = ?`,
468
+ ).run(row.newEmail, row.userId)
469
+ return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
470
+ }