@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.
- package/README.md +92 -0
- package/migrations/0001_init.sql +69 -0
- package/migrations/0002_identity.sql +24 -0
- package/migrations/0003_federation_peers.sql +21 -0
- package/migrations/0003_users_rbac.sql +8 -0
- package/migrations/0004_oidc_op.sql +61 -0
- package/migrations/0005_upstream_providers.sql +34 -0
- package/migrations/0006_op_accounts.sql +28 -0
- package/migrations/0007_org_join_requests.sql +26 -0
- package/migrations/0008_op_client_roles.sql +19 -0
- package/migrations/0009_account_console.sql +32 -0
- package/migrations/0009_sso_states.sql +13 -0
- package/migrations/0010_notify_events.sql +23 -0
- package/migrations/0011_op_launch.sql +18 -0
- package/migrations/0011_org_memberships.sql +62 -0
- package/migrations/0012_notify_subscriptions.sql +57 -0
- package/migrations/0012_strong_auth.sql +109 -0
- package/migrations/0013_org_registry.sql +53 -0
- package/migrations/0014_notify_inbox.sql +34 -0
- package/migrations/0015_certificate_holder_attribution.sql +63 -0
- package/migrations/0016_instrument_registrations.sql +78 -0
- package/package.json +52 -0
- package/src/client-info.ts +25 -0
- package/src/context.ts +31 -0
- package/src/github.ts +284 -0
- package/src/mailer.ts +309 -0
- package/src/oidc.ts +369 -0
- package/src/profile/node.ts +83 -0
- package/src/profile.ts +582 -0
- package/src/rbac/node.ts +42 -0
- package/src/rbac.ts +53 -0
- package/src/session.ts +45 -0
- package/src/store/d1.ts +2850 -0
- package/src/store/sqlite/entities.ts +71 -0
- package/src/store/sqlite/events.ts +82 -0
- package/src/store/sqlite/factors-store.ts +348 -0
- package/src/store/sqlite/notify.ts +247 -0
- package/src/store/sqlite/op-accounts-store.ts +470 -0
- package/src/store/sqlite/op-store.ts +280 -0
- package/src/store/sqlite/schema.sql +745 -0
- package/src/store/sqlite/store.ts +1390 -0
- package/src/store/sqlite/upstream-store.ts +148 -0
- package/src/store/sqlite.ts +1027 -0
- package/src/store.ts +1826 -0
- package/src/vocab/index.ts +12 -0
- package/src/vocab/permissions.ts +398 -0
- package/src/vocab/rbac.ts +281 -0
- 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
|
+
}
|