@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,71 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // The workflow entity store (TODO.ops/07 — server-side persistence).
3
+ // One JSON document per entity keyed by (store, id) — the same shape
4
+ // the browser's IndexedDB stores hold, so the two repository backends
5
+ // are contract-identical. Every write journals into entity_changes —
6
+ // the SSE stream tails it.
7
+ //
8
+ // TODO.cs-e2e/14: the PURE vocabulary (row/change types, ORG_FIELDS,
9
+ // CATALOG_STORES, orgIdOf) lives in ./backend — the worker-safe seam
10
+ // both store implementations share. This module keeps the SQLite
11
+ // (better-sqlite3, node-only) sync implementation and re-exports the
12
+ // vocabulary so existing importers are undisturbed.
13
+ // ═══════════════════════════════════════════════════════════════════
14
+
15
+ import { getDb } from './store'
16
+ import { orgIdOf } from '../../store'
17
+
18
+ export type { EntityRow, EntityChange } from '../../store'
19
+ export { ORG_FIELDS, CATALOG_STORES, orgIdOf } from '../../store'
20
+
21
+ import type { EntityRow, EntityChange } from '../../store'
22
+
23
+ export function listEntities(store: string): EntityRow[] {
24
+ return getDb()
25
+ .prepare('SELECT store, id, org_id, data, updated_at FROM entities WHERE store = ?')
26
+ .all(store) as EntityRow[]
27
+ }
28
+
29
+ export function getEntity(store: string, id: string): EntityRow | undefined {
30
+ return getDb()
31
+ .prepare('SELECT store, id, org_id, data, updated_at FROM entities WHERE store = ? AND id = ?')
32
+ .get(store, id) as EntityRow | undefined
33
+ }
34
+
35
+ export function putEntity(store: string, id: string, orgId: string | null, data: string): void {
36
+ const db = getDb()
37
+ const write = db.transaction(() => {
38
+ db.prepare(
39
+ `INSERT INTO entities (store, id, org_id, data, updated_at) VALUES (?, ?, ?, ?, datetime('now'))
40
+ ON CONFLICT (store, id) DO UPDATE SET org_id = excluded.org_id, data = excluded.data, updated_at = datetime('now')`,
41
+ ).run(store, id, orgId, data)
42
+ db.prepare('INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)').run(store, 'persist', id)
43
+ })
44
+ write()
45
+ }
46
+
47
+ export function deleteEntity(store: string, id: string): boolean {
48
+ const db = getDb()
49
+ let gone = false
50
+ const write = db.transaction(() => {
51
+ const res = db.prepare('DELETE FROM entities WHERE store = ? AND id = ?').run(store, id)
52
+ if (res.changes > 0) {
53
+ db.prepare('INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)').run(store, 'remove', id)
54
+ gone = true
55
+ }
56
+ })
57
+ write()
58
+ return gone
59
+ }
60
+
61
+ /** The journal tail for the SSE stream: changes past a cursor. */
62
+ export function changesAfter(seq: number, limit = 500): EntityChange[] {
63
+ return getDb()
64
+ .prepare('SELECT seq, store, type, id, at FROM entity_changes WHERE seq > ? ORDER BY seq LIMIT ?')
65
+ .all(seq, limit) as EntityChange[]
66
+ }
67
+
68
+ export function latestChangeSeq(): number {
69
+ const row = getDb().prepare('SELECT MAX(seq) AS seq FROM entity_changes').get() as { seq: number | null }
70
+ return row.seq ?? 0
71
+ }
@@ -0,0 +1,82 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // The platform event store (TODO.notify/01) — the SQLite (better-
3
+ // sqlite3, node-only) sync implementation, the entities.ts pattern:
4
+ // one row per declared notifiable act, the key SPLIT into columns
5
+ // (domain, entity_id, action) so the subscription grammar's prefixes
6
+ // resolve in SQL. The D1 store (../d1.ts) runs the SAME statements
7
+ // against the binding; the d1-store suite's tripwire pins the two
8
+ // schemas in lockstep.
9
+ // ═══════════════════════════════════════════════════════════════════
10
+
11
+ import { getDb } from './store'
12
+ import type { EventKeyFilter, PlatformEvent } from '../../store'
13
+
14
+ interface EventRow {
15
+ seq: number
16
+ id: string
17
+ domain: string
18
+ entity_id: string
19
+ action: string
20
+ payload: string
21
+ at: string
22
+ }
23
+
24
+ function toPlatformEvent(row: EventRow): PlatformEvent {
25
+ return {
26
+ seq: row.seq,
27
+ id: row.id,
28
+ domain: row.domain,
29
+ entityId: row.entity_id,
30
+ action: row.action,
31
+ payload: row.payload,
32
+ at: row.at,
33
+ }
34
+ }
35
+
36
+ export function appendEvent(input: {
37
+ id: string
38
+ domain: string
39
+ entityId: string
40
+ action: string
41
+ payload: string
42
+ }): PlatformEvent {
43
+ const db = getDb()
44
+ db.prepare(
45
+ `INSERT INTO events (id, domain, entity_id, action, payload) VALUES (?, ?, ?, ?, ?)`,
46
+ ).run(input.id, input.domain, input.entityId, input.action, input.payload)
47
+ return toPlatformEvent(
48
+ db.prepare('SELECT * FROM events WHERE id = ?').get(input.id) as EventRow,
49
+ )
50
+ }
51
+
52
+ /** The feed's raw leg: events past the cursor, seq-ordered. */
53
+ export function eventsAfter(seq: number, limit = 500): PlatformEvent[] {
54
+ const rows = getDb()
55
+ .prepare('SELECT * FROM events WHERE seq > ? ORDER BY seq LIMIT ?')
56
+ .all(seq, limit) as EventRow[]
57
+ return rows.map(toPlatformEvent)
58
+ }
59
+
60
+ export function latestEventSeq(): number {
61
+ const row = getDb().prepare('SELECT MAX(seq) AS seq FROM events').get() as { seq: number | null }
62
+ return row.seq ?? 0
63
+ }
64
+
65
+ /** The by-id read (the inbox state write's guard). */
66
+ export function getEvent(id: string): PlatformEvent | null {
67
+ const row = getDb().prepare('SELECT * FROM events WHERE id = ?').get(id) as EventRow | undefined
68
+ return row ? toPlatformEvent(row) : null
69
+ }
70
+
71
+ /** The subscription grammar's SQL resolution: the pinned columns match
72
+ * by equality; the free legs stay out of the WHERE. */
73
+ export function eventsMatching(filter: EventKeyFilter, limit = 500): PlatformEvent[] {
74
+ const where: string[] = []
75
+ const args: unknown[] = []
76
+ if (filter.domain !== undefined) { where.push('domain = ?'); args.push(filter.domain) }
77
+ if (filter.entityId !== undefined) { where.push('entity_id = ?'); args.push(filter.entityId) }
78
+ if (filter.action !== undefined) { where.push('action = ?'); args.push(filter.action) }
79
+ const sql = `SELECT * FROM events${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY seq LIMIT ?`
80
+ const rows = getDb().prepare(sql).all(...args, limit) as EventRow[]
81
+ return rows.map(toPlatformEvent)
82
+ }
@@ -0,0 +1,348 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // The factor registry's SQLite half (TODO.identity-sso/02 passkeys + /03
3
+ // the factor registry) — the sync implementations behind the ServerStore
4
+ // strong-authentication methods (sqlite-server-store.ts delegates here
5
+ // one-for-one, mirroring op-accounts-store.ts's role for the account
6
+ // model). The D1 store implements the same surface in d1.ts.
7
+ //
8
+ // The doctrines carried:
9
+ // - ONE-TIME means one-time: challenges, the pending-MFA row and the
10
+ // recovery codes consume via guarded UPDATEs (consumed_at IS NULL) —
11
+ // a replay/concurrent double loses the race, honestly;
12
+ // - the signature counter's advance is a GUARDED update (the clone
13
+ // rule): a regressed counter never lands, and the refusal is named
14
+ // ('regressed') for the audit event;
15
+ // - the throttles ride the ROWS (fail_count + last_failure_at on the
16
+ // pending enrollment / the pending sign-in) — the database is the
17
+ // proof, never a per-process Map;
18
+ // - recovery codes are stored HASHED (SHA-256 of the normalized code);
19
+ // the plaintext is shown once at generation and never persists.
20
+ //
21
+ // NODE-ONLY: better-sqlite3 through ./store's getDb. The Worker bundle
22
+ // never sees this module.
23
+ // ═══════════════════════════════════════════════════════════════════
24
+
25
+ import { randomUUID } from 'crypto'
26
+ import { getDb } from './store'
27
+ import type {
28
+ AdvanceCounterResult,
29
+ MfaPending,
30
+ RecoveryCodeState,
31
+ TotpSecret,
32
+ WebauthnChallenge,
33
+ WebauthnCredential,
34
+ } from '../../store'
35
+
36
+ // ── row mappers ──────────────────────────────────────────────────────
37
+
38
+ /** The store's time columns arrive in two shapes: datetime('now')'s
39
+ * UTC-but-unadorned 'YYYY-MM-DD HH:MM:SS' (the DEFAULT writes) and the
40
+ * ISO strings the code paths write explicitly. The API answers ISO
41
+ * always (Date.parse treats the naive shape as LOCAL time — the age
42
+ * math the routes run would misfire off-UTC). */
43
+ export function storeTimeToIso(value: string | null): string | null {
44
+ if (value === null) return null
45
+ if (value.includes('T')) return value
46
+ return value.replace(' ', 'T') + 'Z'
47
+ }
48
+
49
+ function toWebauthnCredential(row: Record<string, unknown>): WebauthnCredential {
50
+ let transports: string[] = []
51
+ try {
52
+ const parsed = JSON.parse((row.transports as string | null) ?? '[]') as unknown
53
+ if (Array.isArray(parsed)) transports = parsed.filter((t): t is string => typeof t === 'string')
54
+ } catch { /* a malformed JSON array never breaks the read — the empty list is honest */ }
55
+ return {
56
+ credentialId: row.credential_id as string,
57
+ userId: row.user_id as string,
58
+ name: row.name as string,
59
+ publicKeyCose: row.public_key as string,
60
+ signCount: Number(row.sign_count ?? 0),
61
+ aaguid: (row.aaguid as string | null) ?? null,
62
+ transports,
63
+ createdAt: storeTimeToIso(row.created_at as string)!,
64
+ lastUsedAt: storeTimeToIso((row.last_used_at as string | null) ?? null),
65
+ lastIp: (row.last_ip as string | null) ?? null,
66
+ }
67
+ }
68
+
69
+ function toTotpSecret(row: Record<string, unknown>): TotpSecret {
70
+ return {
71
+ id: row.id as string,
72
+ userId: row.user_id as string,
73
+ name: row.name as string,
74
+ secret: row.secret as string,
75
+ failCount: Number(row.fail_count ?? 0),
76
+ lastFailureAt: storeTimeToIso((row.last_failure_at as string | null) ?? null),
77
+ createdAt: storeTimeToIso(row.created_at as string)!,
78
+ verifiedAt: storeTimeToIso((row.verified_at as string | null) ?? null),
79
+ lastUsedAt: storeTimeToIso((row.last_used_at as string | null) ?? null),
80
+ lastIp: (row.last_ip as string | null) ?? null,
81
+ }
82
+ }
83
+
84
+ function toWebauthnChallenge(row: Record<string, unknown>): WebauthnChallenge {
85
+ return {
86
+ challenge: row.challenge as string,
87
+ userId: (row.user_id as string | null) ?? null,
88
+ kind: row.kind as WebauthnChallenge['kind'],
89
+ createdAt: storeTimeToIso(row.created_at as string)!,
90
+ expiresAt: storeTimeToIso(row.expires_at as string)!,
91
+ consumedAt: storeTimeToIso((row.consumed_at as string | null) ?? null),
92
+ }
93
+ }
94
+
95
+ function toMfaPending(row: Record<string, unknown>): MfaPending {
96
+ let amr: string[] = []
97
+ try {
98
+ const parsed = JSON.parse(row.amr as string) as unknown
99
+ if (Array.isArray(parsed)) amr = parsed.filter((a): a is string => typeof a === 'string')
100
+ } catch { /* a malformed amr list reads as none — the row is mid-flight state */ }
101
+ return {
102
+ token: row.token as string,
103
+ userId: row.user_id as string,
104
+ amr,
105
+ failCount: Number(row.fail_count ?? 0),
106
+ lastFailureAt: storeTimeToIso((row.last_failure_at as string | null) ?? null),
107
+ createdAt: storeTimeToIso(row.created_at as string)!,
108
+ expiresAt: storeTimeToIso(row.expires_at as string)!,
109
+ consumedAt: storeTimeToIso((row.consumed_at as string | null) ?? null),
110
+ }
111
+ }
112
+
113
+ // ── the WebAuthn ceremony challenges (one-time, short-TTL) ───────────
114
+
115
+ export function createWebauthnChallenge(input: {
116
+ challenge: string
117
+ userId: string | null
118
+ kind: WebauthnChallenge['kind']
119
+ ttlMs: number
120
+ }): void {
121
+ const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
122
+ const db = getDb()
123
+ // The sweep rides the write (the putSsoState pattern): expired rows go.
124
+ db.prepare("DELETE FROM webauthn_challenges WHERE expires_at <= datetime('now')").run()
125
+ db.prepare(
126
+ 'INSERT INTO webauthn_challenges (challenge, user_id, kind, expires_at) VALUES (?, ?, ?, ?)',
127
+ ).run(input.challenge, input.userId, input.kind, expiresAt)
128
+ }
129
+
130
+ /** Consume atomically: the row answers exactly once; an expired row is
131
+ * consumed too (burned on presentation, never redeemed later). */
132
+ export function consumeWebauthnChallenge(challenge: string): WebauthnChallenge | null {
133
+ const db = getDb()
134
+ const res = db.prepare(
135
+ "UPDATE webauthn_challenges SET consumed_at = datetime('now') WHERE challenge = ? AND consumed_at IS NULL",
136
+ ).run(challenge)
137
+ if (res.changes === 0) return null
138
+ const row = db.prepare('SELECT * FROM webauthn_challenges WHERE challenge = ?').get(challenge) as Record<string, unknown> | undefined
139
+ if (!row) return null
140
+ if (new Date(row.expires_at as string).getTime() <= Date.now()) return null
141
+ return toWebauthnChallenge(row)
142
+ }
143
+
144
+ // ── the passkeys ─────────────────────────────────────────────────────
145
+
146
+ /** Register the passkey; answers null on the credential-id conflict (the
147
+ * PRIMARY KEY is the race backstop — one authenticator registers once,
148
+ * to one account). */
149
+ export function createWebauthnCredential(input: {
150
+ credentialId: string
151
+ userId: string
152
+ name: string
153
+ publicKeyCose: string
154
+ signCount: number
155
+ aaguid: string | null
156
+ transports: string[]
157
+ ip?: string | null
158
+ }): WebauthnCredential | null {
159
+ const db = getDb()
160
+ try {
161
+ db.prepare(
162
+ `INSERT INTO webauthn_credentials
163
+ (credential_id, user_id, name, public_key, sign_count, aaguid, transports, last_ip)
164
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
165
+ ).run(
166
+ input.credentialId, input.userId, input.name, input.publicKeyCose,
167
+ Math.max(0, Math.floor(input.signCount)), input.aaguid, JSON.stringify(input.transports),
168
+ input.ip ?? null,
169
+ )
170
+ } catch (e) {
171
+ if (String((e as Error).message).includes('UNIQUE')) return null
172
+ throw e
173
+ }
174
+ return getWebauthnCredential(input.credentialId)
175
+ }
176
+
177
+ export function listWebauthnCredentials(userId: string): WebauthnCredential[] {
178
+ const rows = getDb().prepare(
179
+ 'SELECT * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at, credential_id',
180
+ ).all(userId) as Array<Record<string, unknown>>
181
+ return rows.map(toWebauthnCredential)
182
+ }
183
+
184
+ export function getWebauthnCredential(credentialId: string): WebauthnCredential | null {
185
+ const row = getDb().prepare(
186
+ 'SELECT * FROM webauthn_credentials WHERE credential_id = ?',
187
+ ).get(credentialId) as Record<string, unknown> | undefined
188
+ return row ? toWebauthnCredential(row) : null
189
+ }
190
+
191
+ export function deleteWebauthnCredential(userId: string, credentialId: string): boolean {
192
+ return getDb().prepare(
193
+ 'DELETE FROM webauthn_credentials WHERE credential_id = ? AND user_id = ?',
194
+ ).run(credentialId, userId).changes > 0
195
+ }
196
+
197
+ /** The guarded counter advance (the clone rule, in SQL so the check and
198
+ * the write are one act): lands when the pair is (0 → 0) — the
199
+ * authenticator never counts — or strictly increasing; a zeroed or
200
+ * behind counter against a started one is the regression signal. */
201
+ export function advanceWebauthnCounter(
202
+ credentialId: string,
203
+ newCount: number,
204
+ opts?: { ip?: string | null },
205
+ ): AdvanceCounterResult {
206
+ const db = getDb()
207
+ const count = Math.max(0, Math.floor(newCount))
208
+ const res = db.prepare(
209
+ `UPDATE webauthn_credentials
210
+ SET sign_count = ?, last_used_at = datetime('now'), last_ip = ?
211
+ WHERE credential_id = ? AND ((sign_count = 0 AND ? = 0) OR sign_count < ?)`,
212
+ ).run(count, opts?.ip ?? null, credentialId, count, count)
213
+ if (res.changes > 0) return 'ok'
214
+ return getWebauthnCredential(credentialId) ? 'regressed' : 'unknown'
215
+ }
216
+
217
+ // ── the TOTP authenticator apps ──────────────────────────────────────
218
+
219
+ /** The enrollment's PENDING row (verified_at NULL — the factor activates
220
+ * at markTotpSecretVerified, never before). */
221
+ export function createTotpSecret(input: { id: string; userId: string; name: string; secret: string }): TotpSecret {
222
+ getDb().prepare(
223
+ 'INSERT INTO totp_secrets (id, user_id, name, secret) VALUES (?, ?, ?, ?)',
224
+ ).run(input.id, input.userId, input.name, input.secret)
225
+ return getTotpSecret(input.id)!
226
+ }
227
+
228
+ export function listTotpSecrets(userId: string): TotpSecret[] {
229
+ const rows = getDb().prepare(
230
+ 'SELECT * FROM totp_secrets WHERE user_id = ? ORDER BY created_at, id',
231
+ ).all(userId) as Array<Record<string, unknown>>
232
+ return rows.map(toTotpSecret)
233
+ }
234
+
235
+ export function getTotpSecret(id: string): TotpSecret | null {
236
+ const row = getDb().prepare('SELECT * FROM totp_secrets WHERE id = ?').get(id) as Record<string, unknown> | undefined
237
+ return row ? toTotpSecret(row) : null
238
+ }
239
+
240
+ export function markTotpSecretVerified(id: string, userId: string, name: string): boolean {
241
+ return getDb().prepare(
242
+ "UPDATE totp_secrets SET verified_at = datetime('now'), name = ? WHERE id = ? AND user_id = ? AND verified_at IS NULL",
243
+ ).run(name, id, userId).changes > 0
244
+ }
245
+
246
+ /** The enrollment verify's failure ladder (the six-digit window's wall):
247
+ * increments fail_count and stamps the failure instant; answers the
248
+ * fresh count (0 when the row is gone or not the account's). */
249
+ export function recordTotpEnrollFailure(id: string, userId: string): number {
250
+ const db = getDb()
251
+ const res = db.prepare(
252
+ "UPDATE totp_secrets SET fail_count = fail_count + 1, last_failure_at = datetime('now') WHERE id = ? AND user_id = ? AND verified_at IS NULL",
253
+ ).run(id, userId)
254
+ if (res.changes === 0) return 0
255
+ const row = db.prepare('SELECT fail_count AS n FROM totp_secrets WHERE id = ?').get(id) as { n: number } | undefined
256
+ return row?.n ?? 0
257
+ }
258
+
259
+ export function markTotpSecretUsed(id: string, opts?: { ip?: string | null }): void {
260
+ getDb().prepare(
261
+ "UPDATE totp_secrets SET last_used_at = datetime('now'), last_ip = ? WHERE id = ?",
262
+ ).run(opts?.ip ?? null, id)
263
+ }
264
+
265
+ export function deleteTotpSecret(userId: string, id: string): boolean {
266
+ return getDb().prepare('DELETE FROM totp_secrets WHERE id = ? AND user_id = ?').run(id, userId).changes > 0
267
+ }
268
+
269
+ // ── the recovery codes ───────────────────────────────────────────────
270
+
271
+ /** Replace the account's set WHOLE (the regenerate): the old batch goes,
272
+ * the new hashes land — one transaction, so a crash never leaves the
273
+ * account with no codes while the console shows fresh ones. */
274
+ export function replaceRecoveryCodes(userId: string, batch: string, codeHashes: string[]): void {
275
+ const db = getDb()
276
+ db.transaction(() => {
277
+ db.prepare('DELETE FROM recovery_codes WHERE user_id = ?').run(userId)
278
+ const insert = db.prepare(
279
+ 'INSERT INTO recovery_codes (id, user_id, batch, code_hash) VALUES (?, ?, ?, ?)',
280
+ )
281
+ for (const hash of codeHashes) insert.run(randomUUID(), userId, batch, hash)
282
+ })()
283
+ }
284
+
285
+ export function recoveryCodeState(userId: string): RecoveryCodeState {
286
+ const row = getDb().prepare(
287
+ `SELECT COUNT(*) AS total,
288
+ SUM(CASE WHEN consumed_at IS NULL THEN 1 ELSE 0 END) AS remaining,
289
+ MAX(created_at) AS created_at
290
+ FROM recovery_codes WHERE user_id = ?`,
291
+ ).get(userId) as { total: number; remaining: number | null; created_at: string | null }
292
+ return {
293
+ total: row.total,
294
+ remaining: row.remaining ?? 0,
295
+ createdAt: row.total > 0 ? row.created_at : null,
296
+ }
297
+ }
298
+
299
+ /** The one-time use: consumed_at flips atomically on the matching
300
+ * unconsumed row — true exactly once per code. */
301
+ export function consumeRecoveryCode(userId: string, codeHash: string): boolean {
302
+ return getDb().prepare(
303
+ "UPDATE recovery_codes SET consumed_at = datetime('now') WHERE user_id = ? AND code_hash = ? AND consumed_at IS NULL",
304
+ ).run(userId, codeHash).changes > 0
305
+ }
306
+
307
+ // ── the pending second-factor sign-in ────────────────────────────────
308
+
309
+ export function createMfaPending(input: { token: string; userId: string; amr: string[]; ttlMs: number }): void {
310
+ const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
311
+ const db = getDb()
312
+ // The sweep rides the write (the challenge table's pattern).
313
+ db.prepare("DELETE FROM mfa_pending WHERE expires_at <= datetime('now')").run()
314
+ db.prepare(
315
+ 'INSERT INTO mfa_pending (token, user_id, amr, expires_at) VALUES (?, ?, ?, ?)',
316
+ ).run(input.token, input.userId, JSON.stringify(input.amr), expiresAt)
317
+ }
318
+
319
+ export function getMfaPending(token: string): MfaPending | null {
320
+ const row = getDb().prepare('SELECT * FROM mfa_pending WHERE token = ?').get(token) as Record<string, unknown> | undefined
321
+ return row ? toMfaPending(row) : null
322
+ }
323
+
324
+ /** The completion: consumed ATOMICALLY (a concurrent completion loses);
325
+ * an expired row burns on presentation, never redeems later. */
326
+ export function consumeMfaPending(token: string): MfaPending | null {
327
+ const db = getDb()
328
+ const res = db.prepare(
329
+ "UPDATE mfa_pending SET consumed_at = datetime('now') WHERE token = ? AND consumed_at IS NULL",
330
+ ).run(token)
331
+ if (res.changes === 0) return null
332
+ const row = db.prepare('SELECT * FROM mfa_pending WHERE token = ?').get(token) as Record<string, unknown> | undefined
333
+ if (!row) return null
334
+ if (new Date(row.expires_at as string).getTime() <= Date.now()) return null
335
+ return toMfaPending(row)
336
+ }
337
+
338
+ /** The failure ladder: fail_count++ + last_failure_at on the LIVE row
339
+ * (a consumed one takes no more failures); answers the fresh row. */
340
+ export function recordMfaPendingFailure(token: string): MfaPending | null {
341
+ const db = getDb()
342
+ const res = db.prepare(
343
+ "UPDATE mfa_pending SET fail_count = fail_count + 1, last_failure_at = datetime('now') WHERE token = ? AND consumed_at IS NULL",
344
+ ).run(token)
345
+ if (res.changes === 0) return null
346
+ const row = db.prepare('SELECT * FROM mfa_pending WHERE token = ?').get(token) as Record<string, unknown> | undefined
347
+ return row ? toMfaPending(row) : null
348
+ }