@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,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
|
+
}
|