@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,1390 @@
1
+ import Database from 'better-sqlite3'
2
+ import bcrypt from 'bcryptjs'
3
+ import { randomUUID } from 'crypto'
4
+ import { readFileSync, existsSync, mkdirSync } from 'fs'
5
+ import { join, dirname } from 'path'
6
+ import { fileURLToPath } from 'url'
7
+
8
+ // The payload type's home is the worker-safe backend seam
9
+ // (TODO.cs-e2e/14); re-exported here so existing importers are
10
+ // undisturbed.
11
+ import type { AuthUserPayload } from '../../store'
12
+ export type { AuthUserPayload } from '../../store'
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url))
15
+
16
+ // The default database path is anchored one level above the process's
17
+ // working directory: every boot that relies on the default (the dev
18
+ // scripts, tsx watch, the seed legs) runs with cwd = the consumer app's
19
+ // directory (browser/ in the smart monorepo), so the effective default
20
+ // is unchanged from the store's pre-extraction home
21
+ // (<repo>/data/oiml-smart.db, gitignored by name). DATABASE_PATH always
22
+ // wins; the deploy postures (Docker, the e2e stacks, the tests) declare
23
+ // it explicitly.
24
+ const DB_PATH = process.env.DATABASE_PATH || join(process.cwd(), '..', 'data', 'oiml-smart.db')
25
+
26
+ let _db: Database.Database | null = null
27
+
28
+ export function getDb(): Database.Database {
29
+ if (_db) return _db
30
+ const dir = dirname(DB_PATH)
31
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
32
+ _db = new Database(DB_PATH)
33
+ _db.pragma('journal_mode = WAL')
34
+ _db.exec(readFileSync(join(__dirname, 'schema.sql'), 'utf-8'))
35
+ migrateAuthTables(_db)
36
+ return _db
37
+ }
38
+
39
+ /** Migration-safe column adds for DBs created before a schema change. */
40
+ function migrateAuthTables(db: Database.Database): void {
41
+ const cols = db.prepare('PRAGMA table_info(users)').all() as Array<{ name: string }>
42
+ if (!cols.some(c => c.name === 'org_id')) {
43
+ db.exec('ALTER TABLE users ADD COLUMN org_id TEXT')
44
+ }
45
+ // TODO.federation/10 — the SSO logout hint (id_token for RP-initiated
46
+ // logout); identity_approvals itself arrives via schema.sql's
47
+ // CREATE IF NOT EXISTS on every boot.
48
+ const sessionCols = db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>
49
+ if (!sessionCols.some(c => c.name === 'id_token_hint')) {
50
+ db.exec('ALTER TABLE sessions ADD COLUMN id_token_hint TEXT')
51
+ }
52
+ // TODO.identity/06 (the account console): the session sign-in context
53
+ // (user agent / IP at creation, last-active on resolution) and the
54
+ // email verification state on the users row.
55
+ if (!sessionCols.some(c => c.name === 'user_agent')) {
56
+ db.exec('ALTER TABLE sessions ADD COLUMN user_agent TEXT')
57
+ }
58
+ if (!sessionCols.some(c => c.name === 'ip')) {
59
+ db.exec('ALTER TABLE sessions ADD COLUMN ip TEXT')
60
+ }
61
+ if (!sessionCols.some(c => c.name === 'last_seen_at')) {
62
+ db.exec('ALTER TABLE sessions ADD COLUMN last_seen_at TEXT')
63
+ }
64
+ if (!cols.some(c => c.name === 'email_verified_at')) {
65
+ db.exec('ALTER TABLE users ADD COLUMN email_verified_at TEXT')
66
+ }
67
+ // TODO.federation/12 (RBAC): the assigned role set + the active flag.
68
+ if (!cols.some(c => c.name === 'roles')) {
69
+ db.exec('ALTER TABLE users ADD COLUMN roles TEXT')
70
+ }
71
+ if (!cols.some(c => c.name === 'active')) {
72
+ db.exec('ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1')
73
+ }
74
+ // The SSO home (migration 0011): the client registry's launch
75
+ // metadata. The launcher's reads degrade honestly without the columns
76
+ // (launch stays null); a pre-0011 SQLite file grows them here.
77
+ const clientCols = db.prepare('PRAGMA table_info(oidc_clients)').all() as Array<{ name: string }>
78
+ if (clientCols.length && !clientCols.some(c => c.name === 'launch_url')) {
79
+ db.exec(`ALTER TABLE oidc_clients ADD COLUMN launch_url TEXT`)
80
+ db.exec(`ALTER TABLE oidc_clients ADD COLUMN launch_icon TEXT`)
81
+ db.exec(`ALTER TABLE oidc_clients ADD COLUMN launch_description TEXT`)
82
+ db.exec(`ALTER TABLE oidc_clients ADD COLUMN launch_visibility TEXT NOT NULL DEFAULT 'roles'`)
83
+ }
84
+ // TODO.identity/11 (the multi-org membership model): the session's
85
+ // active-org stamp + the token-flow context columns (the code carries
86
+ // the consent's context; the access token inherits it for userinfo).
87
+ if (!sessionCols.some(c => c.name === 'active_org')) {
88
+ db.exec('ALTER TABLE sessions ADD COLUMN active_org TEXT')
89
+ }
90
+ const codeCols = db.prepare('PRAGMA table_info(oidc_codes)').all() as Array<{ name: string }>
91
+ if (!codeCols.some(c => c.name === 'context_org')) {
92
+ db.exec('ALTER TABLE oidc_codes ADD COLUMN context_org TEXT')
93
+ }
94
+ const accessCols = db.prepare('PRAGMA table_info(oidc_access_tokens)').all() as Array<{ name: string }>
95
+ if (!accessCols.some(c => c.name === 'context_org')) {
96
+ db.exec('ALTER TABLE oidc_access_tokens ADD COLUMN context_org TEXT')
97
+ }
98
+ // The backfill (migration 0012's twin, IDEMPOTENT — it rides every
99
+ // boot): every org-bound account's PRIMARY membership, mirrored from
100
+ // the legacy columns (the dual-read doctrine's foundation). The
101
+ // deterministic id keeps re-runs no-ops; the roles column is the
102
+ // account's full legacy set (or the primary role when NULL).
103
+ db.exec(`
104
+ INSERT OR IGNORE INTO org_memberships (id, user_id, org_id, roles, state, is_primary, activated_at)
105
+ SELECT 'mbr-' || id, id, org_id,
106
+ CASE WHEN roles IS NOT NULL AND roles != '' THEN roles ELSE json_array(role) END,
107
+ 'active', 1, COALESCE(last_login, created_at)
108
+ FROM users WHERE org_id IS NOT NULL
109
+ `)
110
+ // TODO.identity-sso/02+03 (the strong-authentication wave): the amr
111
+ // provenance columns on sessions → codes → access tokens.
112
+ if (!sessionCols.some(c => c.name === 'amr')) {
113
+ db.exec('ALTER TABLE sessions ADD COLUMN amr TEXT')
114
+ }
115
+ const amrCodeCols = db.prepare('PRAGMA table_info(oidc_codes)').all() as Array<{ name: string }>
116
+ if (!amrCodeCols.some(c => c.name === 'amr')) {
117
+ db.exec('ALTER TABLE oidc_codes ADD COLUMN amr TEXT')
118
+ }
119
+ const amrTokenCols = db.prepare('PRAGMA table_info(oidc_access_tokens)').all() as Array<{ name: string }>
120
+ if (!amrTokenCols.some(c => c.name === 'amr')) {
121
+ db.exec('ALTER TABLE oidc_access_tokens ADD COLUMN amr TEXT')
122
+ }
123
+ }
124
+
125
+ // AuthUserPayload lives in ./backend (see the re-export above).
126
+
127
+ // The demo cast's home is the worker-safe backend seam (TODO.cs-e2e/14 —
128
+ // the D1 store seeds the same accounts); the password constant is imported,
129
+ // the account list now flows through the profile-aware plan below.
130
+ import { DEMO_PASSWORD } from '../../store'
131
+ // TODO.federation/01 — the account plan follows the deployment profile:
132
+ // the hub seeds DEMO_ACCOUNTS (the historical cast); an ia/tl instance
133
+ // seeds its own staff (+ the cast when the profile carries the
134
+ // demo-personas flag). The installed slot defaults to the hub profile,
135
+ // so a boot with no profile declaration behaves exactly as before.
136
+ import { getInstanceProfile, seedAccountsForProfile } from '../../profile'
137
+
138
+ export function seedDemoAccounts(): void {
139
+ const db = getDb()
140
+ const insert = db.prepare(`
141
+ INSERT OR IGNORE INTO users (id, email, name, provider, provider_account_id, role, org_id, roles)
142
+ VALUES (?, ?, ?, 'demo', ?, ?, ?, ?)
143
+ `)
144
+ // Align existing demo rows (created by older seeds) with the current
145
+ // role/org assignments — INSERT OR IGNORE alone would leave them stale.
146
+ // The roles column aligns too (NULL when the seed declares no full set
147
+ // — a stale set from an older cast never survives the align).
148
+ const align = db.prepare(`
149
+ UPDATE users SET name = ?, role = ?, org_id = ?, roles = ? WHERE email = ? AND provider = 'demo'
150
+ `)
151
+
152
+ for (const account of seedAccountsForProfile(getInstanceProfile())) {
153
+ const roles = account.roles?.length ? JSON.stringify(account.roles) : null
154
+ insert.run(randomUUID(), account.email, account.name, account.email, account.role, account.orgId, roles)
155
+ align.run(account.name, account.role, account.orgId, roles, account.email)
156
+ // TODO.identity/11: the org-bound seed accounts' primary memberships
157
+ // ride the mirror (idempotent — the seed runs at every boot).
158
+ if (account.orgId) {
159
+ const row = db.prepare('SELECT id FROM users WHERE email = ?').get(account.email) as { id: string } | undefined
160
+ if (row) syncPrimaryMembership(row.id)
161
+ }
162
+ }
163
+ }
164
+
165
+ /** Parse the users.roles JSON column (the assigned role set; NULL →
166
+ * the primary role only). */
167
+ function parseRoles(raw: unknown): string[] | undefined {
168
+ if (typeof raw !== 'string' || !raw) return undefined
169
+ try {
170
+ const parsed = JSON.parse(raw) as unknown
171
+ return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : undefined
172
+ } catch {
173
+ return undefined
174
+ }
175
+ }
176
+
177
+ /** The users-row → session payload projection (RBAC: roles + the
178
+ * active flag are part of the row; a deactivated account never
179
+ * produces a payload). */
180
+ function toAuthPayload(user: any, avatarUrl?: string): AuthUserPayload {
181
+ const roles = parseRoles(user.roles)
182
+ // TODO.identity-sso/02+03: the session row's amr (a JSON array) — only
183
+ // the session-backed read carries it; a plain user row answers absent.
184
+ const amr = parseRoles(user.amr) ?? undefined
185
+ return {
186
+ id: user.id,
187
+ email: user.email,
188
+ name: user.name,
189
+ role: user.role,
190
+ ...(roles?.length ? { roles } : {}),
191
+ orgId: user.org_id ?? null,
192
+ avatarUrl: avatarUrl ?? user.avatar_url,
193
+ provider: user.provider,
194
+ // TODO.identity/06: the primary address's verification state (NULL on
195
+ // rows that predate the console; the account page shows it honestly).
196
+ emailVerifiedAt: user.email_verified_at ?? null,
197
+ ...(amr?.length ? { amr } : {}),
198
+ }
199
+ }
200
+
201
+ export function authenticateDemo(email: string, password: string): AuthUserPayload | null {
202
+ const db = getDb()
203
+ const user = db.prepare("SELECT * FROM users WHERE email = ? AND provider = 'demo'").get(email) as any
204
+ if (!user) return null
205
+ // A deactivated account refuses sign-in (TODO.federation/12).
206
+ if (user.active === 0) return null
207
+
208
+ if (password === DEMO_PASSWORD) {
209
+ db.prepare("UPDATE users SET last_login = datetime('now') WHERE id = ?").run(user.id)
210
+ return toAuthPayload(user)
211
+ }
212
+ return null
213
+ }
214
+
215
+ export function findOrCreateOAuthUser(
216
+ provider: string,
217
+ providerAccountId: string,
218
+ email: string,
219
+ name: string,
220
+ avatarUrl?: string,
221
+ // The INITIAL role/org (backend.ts's OAuthInitialAssignment) — applied
222
+ // only on CREATE; an existing account keeps its local assignment.
223
+ initial?: import('../../store').OAuthInitialAssignment,
224
+ ): AuthUserPayload {
225
+ const db = getDb()
226
+
227
+ const existing = db.prepare(
228
+ 'SELECT * FROM users WHERE provider = ? AND provider_account_id = ?'
229
+ ).get(provider, providerAccountId) as any
230
+
231
+ if (existing) {
232
+ db.prepare('UPDATE users SET last_login = datetime(\'now\'), avatar_url = ? WHERE id = ?')
233
+ .run(avatarUrl ?? null, existing.id)
234
+ return {
235
+ id: existing.id,
236
+ email: existing.email,
237
+ name: existing.name,
238
+ role: existing.role,
239
+ orgId: existing.org_id ?? null,
240
+ avatarUrl: avatarUrl ?? existing.avatar_url,
241
+ }
242
+ }
243
+
244
+ const id = randomUUID()
245
+ const role = initial?.role ?? 'user'
246
+ const orgId = initial?.orgId ?? null
247
+ db.prepare(
248
+ 'INSERT INTO users (id, email, name, avatar_url, provider, provider_account_id, role, org_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
249
+ ).run(id, email, name, avatarUrl ?? null, provider, providerAccountId, role, orgId)
250
+ if (orgId) syncPrimaryMembership(id) // TODO.identity/11 — the mirror
251
+
252
+ return { id, email, name, role, orgId, avatarUrl }
253
+ }
254
+
255
+ export function createSession(
256
+ userId: string,
257
+ opts?: { idTokenHint?: string | null; userAgent?: string | null; ip?: string | null; amr?: string[] | null },
258
+ ): string {
259
+ const db = getDb()
260
+ const token = randomUUID()
261
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
262
+ db.prepare('INSERT INTO sessions (id, user_id, token, expires_at, id_token_hint, user_agent, ip, last_seen_at, amr) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
263
+ .run(randomUUID(), userId, token, expiresAt, opts?.idTokenHint ?? null, opts?.userAgent ?? null, opts?.ip ?? null, null,
264
+ opts?.amr?.length ? JSON.stringify(opts.amr) : null)
265
+ return token
266
+ }
267
+
268
+ /** Stamp the account's last sign-in (TODO.identity/07 — the OP's own
269
+ * sign-in paths call this; the demo/OAuth paths bump it inline). */
270
+ export function touchLastLogin(userId: string): void {
271
+ getDb().prepare("UPDATE users SET last_login = datetime('now') WHERE id = ?").run(userId)
272
+ }
273
+
274
+ export function getSessionUser(token: string): AuthUserPayload | null {
275
+ const db = getDb()
276
+ // The session joins the LIVE user row: a role reassignment takes
277
+ // effect on the next request, and a deactivated account's sessions
278
+ // stop resolving at once (TODO.federation/12).
279
+ const session = db.prepare(`
280
+ SELECT s.*, u.email, u.name, u.role, u.roles, u.org_id, u.avatar_url, u.provider, u.email_verified_at
281
+ FROM sessions s JOIN users u ON s.user_id = u.id
282
+ WHERE s.token = ? AND s.expires_at > datetime('now') AND u.active = 1
283
+ `).get(token) as any
284
+ if (!session) return null
285
+ // TODO.identity/06: the last-active stamp, throttled to one write per
286
+ // minute per session (the account console's sessions section shows it).
287
+ db.prepare(
288
+ "UPDATE sessions SET last_seen_at = datetime('now') WHERE token = ? AND (last_seen_at IS NULL OR last_seen_at < datetime('now', '-60 seconds'))",
289
+ ).run(token)
290
+ // s.* carries the SESSION's id — the payload's id is the USER's.
291
+ const payload = toAuthPayload({ ...session, id: session.user_id })
292
+ // TODO.identity/11: the active-org context (the membership model) —
293
+ // the payload's org/roles follow the session's stamped context.
294
+ return applySessionOrgContext(session.active_org ?? null, payload)
295
+ }
296
+
297
+ export function deleteSession(token: string): void {
298
+ const db = getDb()
299
+ db.prepare('DELETE FROM sessions WHERE token = ?').run(token)
300
+ }
301
+
302
+ export function cleanExpiredSessions(): void {
303
+ const db = getDb()
304
+ db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run()
305
+ }
306
+
307
+ // ── identity federation (TODO.federation/10) ──────────────────────────
308
+
309
+ import type { IdentityApproval } from '../../store'
310
+
311
+ interface UserRow {
312
+ id: string
313
+ email: string
314
+ name: string
315
+ role: string
316
+ /** The full assigned role set as a JSON array (fed-12); NULL = the
317
+ * primary role only. */
318
+ roles: string | null
319
+ org_id: string | null
320
+ avatar_url: string | null
321
+ provider: string
322
+ email_verified_at?: string | null
323
+ }
324
+
325
+ function userPayload(row: UserRow): AuthUserPayload {
326
+ // The FULL assigned role set rides along (the OP's claim emission reads
327
+ // it — the ID token's roles claim must reflect an assignment, not just
328
+ // the primary role); absent = the primary role only.
329
+ const roles = parseRoles(row.roles)
330
+ return {
331
+ id: row.id,
332
+ email: row.email,
333
+ name: row.name,
334
+ role: row.role,
335
+ ...(roles?.length ? { roles } : {}),
336
+ orgId: row.org_id ?? null,
337
+ avatarUrl: row.avatar_url ?? undefined,
338
+ provider: row.provider,
339
+ emailVerifiedAt: row.email_verified_at ?? null,
340
+ }
341
+ }
342
+
343
+ export function getSessionIdTokenHint(token: string): string | null {
344
+ const db = getDb()
345
+ const row = db.prepare(
346
+ "SELECT id_token_hint FROM sessions WHERE token = ? AND expires_at > datetime('now')",
347
+ ).get(token) as { id_token_hint: string | null } | undefined
348
+ return row?.id_token_hint ?? null
349
+ }
350
+
351
+ export function findUserByEmail(email: string): AuthUserPayload | null {
352
+ const row = getDb().prepare('SELECT * FROM users WHERE email = ?').get(email) as UserRow | undefined
353
+ return row ? userPayload(row) : null
354
+ }
355
+
356
+ /** The account by its id (TODO.identity/01 — the OP's token endpoint
357
+ * resolves the code's user_id). */
358
+ export function getUserById(id: string): AuthUserPayload | null {
359
+ const row = getDb().prepare('SELECT * FROM users WHERE id = ?').get(id) as UserRow | undefined
360
+ return row ? userPayload(row) : null
361
+ }
362
+
363
+ export function findUserByProvider(provider: string, providerAccountId: string): AuthUserPayload | null {
364
+ const row = getDb().prepare(
365
+ 'SELECT * FROM users WHERE provider = ? AND provider_account_id = ?',
366
+ ).get(provider, providerAccountId) as UserRow | undefined
367
+ return row ? userPayload(row) : null
368
+ }
369
+
370
+ export function provisionSsoUser(input: {
371
+ email: string
372
+ name: string
373
+ provider: string
374
+ providerAccountId: string
375
+ role: string
376
+ orgId: string | null
377
+ }): AuthUserPayload {
378
+ const db = getDb()
379
+ const id = randomUUID()
380
+ db.prepare(
381
+ 'INSERT INTO users (id, email, name, provider, provider_account_id, role, org_id, last_login) VALUES (?, ?, ?, ?, ?, ?, ?, datetime(\'now\'))',
382
+ ).run(id, input.email, input.name, input.provider, input.providerAccountId, input.role, input.orgId)
383
+ if (input.orgId) syncPrimaryMembership(id) // TODO.identity/11 — the mirror
384
+ return { id, email: input.email, name: input.name, role: input.role, orgId: input.orgId }
385
+ }
386
+
387
+ export function linkProviderIdentity(userId: string, provider: string, providerAccountId: string): void {
388
+ const db = getDb()
389
+ db.prepare('UPDATE users SET provider = ?, provider_account_id = ?, last_login = datetime(\'now\') WHERE id = ?')
390
+ .run(provider, providerAccountId, userId)
391
+ }
392
+
393
+ export function updateUserRoleOrg(userId: string, role: string, orgId: string | null): void {
394
+ getDb().prepare('UPDATE users SET role = ?, org_id = ? WHERE id = ?').run(role, orgId, userId)
395
+ if (orgId) syncPrimaryMembership(userId) // TODO.identity/11 — the mirror
396
+ }
397
+
398
+ interface IdentityApprovalRow {
399
+ id: string
400
+ email: string
401
+ name: string
402
+ issuer: string
403
+ sub: string
404
+ claims_json: string | null
405
+ status: 'pending' | 'approved' | 'rejected'
406
+ decided_role: string | null
407
+ decided_org: string | null
408
+ decided_by: string | null
409
+ created_at: string
410
+ last_seen: string | null
411
+ decided_at: string | null
412
+ }
413
+
414
+ function approvalPayload(row: IdentityApprovalRow): IdentityApproval {
415
+ return {
416
+ id: row.id,
417
+ email: row.email,
418
+ name: row.name,
419
+ issuer: row.issuer,
420
+ sub: row.sub,
421
+ claimsJson: row.claims_json,
422
+ status: row.status,
423
+ decidedRole: row.decided_role,
424
+ decidedOrg: row.decided_org,
425
+ decidedBy: row.decided_by,
426
+ createdAt: row.created_at,
427
+ lastSeen: row.last_seen ?? row.created_at,
428
+ decidedAt: row.decided_at,
429
+ }
430
+ }
431
+
432
+ export function upsertIdentityApproval(input: {
433
+ email: string
434
+ name: string
435
+ issuer: string
436
+ sub: string
437
+ claimsJson: string | null
438
+ }): IdentityApproval {
439
+ const db = getDb()
440
+ db.prepare(
441
+ `INSERT INTO identity_approvals (id, email, name, issuer, sub, claims_json, last_seen)
442
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
443
+ ON CONFLICT (issuer, sub) DO UPDATE SET
444
+ email = excluded.email, name = excluded.name, claims_json = excluded.claims_json,
445
+ last_seen = datetime('now')`,
446
+ ).run(randomUUID(), input.email, input.name, input.issuer, input.sub, input.claimsJson)
447
+ const row = db.prepare('SELECT * FROM identity_approvals WHERE issuer = ? AND sub = ?')
448
+ .get(input.issuer, input.sub) as IdentityApprovalRow
449
+ return approvalPayload(row)
450
+ }
451
+
452
+ export function getIdentityApproval(issuer: string, sub: string): IdentityApproval | null {
453
+ const row = getDb().prepare('SELECT * FROM identity_approvals WHERE issuer = ? AND sub = ?')
454
+ .get(issuer, sub) as IdentityApprovalRow | undefined
455
+ return row ? approvalPayload(row) : null
456
+ }
457
+
458
+ export function listIdentityApprovals(status?: IdentityApproval['status']): IdentityApproval[] {
459
+ const rows = (status
460
+ ? getDb().prepare('SELECT * FROM identity_approvals WHERE status = ? ORDER BY created_at').all(status)
461
+ : getDb().prepare('SELECT * FROM identity_approvals ORDER BY created_at').all()) as IdentityApprovalRow[]
462
+ return rows.map(approvalPayload)
463
+ }
464
+
465
+ export function decideIdentityApproval(
466
+ id: string,
467
+ decision: { status: 'approved' | 'rejected'; role?: string; orgId?: string | null; decidedBy: string },
468
+ ): IdentityApproval | null {
469
+ const db = getDb()
470
+ db.prepare(
471
+ `UPDATE identity_approvals SET status = ?, decided_role = ?, decided_org = ?, decided_by = ?, decided_at = datetime('now')
472
+ WHERE id = ?`,
473
+ ).run(decision.status, decision.role ?? null, decision.orgId ?? null, decision.decidedBy, id)
474
+ const row = db.prepare('SELECT * FROM identity_approvals WHERE id = ?').get(id) as IdentityApprovalRow | undefined
475
+ return row ? approvalPayload(row) : null
476
+ }
477
+
478
+ // ── the SSO sign-in state jar (TODO.identity/04) ────────────────────
479
+
480
+ import type { SsoSignInState } from '../../store'
481
+
482
+ interface SsoStateRow {
483
+ state: string
484
+ nonce: string
485
+ verifier: string
486
+ expires_at: string
487
+ }
488
+
489
+ export function putSsoState(input: { state: string; nonce: string; verifier: string; ttlMs: number }): void {
490
+ const db = getDb()
491
+ // The expiry sweep rides the write (the per-process Map's discipline):
492
+ // stale rows are inert — consume checks the expiry — but never
493
+ // accumulate.
494
+ db.prepare('DELETE FROM sso_states WHERE expires_at <= ?').run(new Date().toISOString())
495
+ db.prepare('INSERT INTO sso_states (state, nonce, verifier, expires_at) VALUES (?, ?, ?, ?)')
496
+ .run(input.state, input.nonce, input.verifier, new Date(Date.now() + input.ttlMs).toISOString())
497
+ }
498
+
499
+ /** Atomically consume the state: the UPDATE flips consumed_at exactly
500
+ * once (a replay loses the race and answers null). An EXPIRED row is
501
+ * consumed too — never a second chance. */
502
+ export function consumeSsoState(state: string): SsoSignInState | null {
503
+ const db = getDb()
504
+ const res = db.prepare("UPDATE sso_states SET consumed_at = datetime('now') WHERE state = ? AND consumed_at IS NULL").run(state)
505
+ if (res.changes === 0) return null
506
+ const row = db.prepare('SELECT state, nonce, verifier, expires_at FROM sso_states WHERE state = ?').get(state) as SsoStateRow | undefined
507
+ if (!row) return null
508
+ if (new Date(row.expires_at).getTime() <= Date.now()) return null
509
+ return { state: row.state, nonce: row.nonce, verifier: row.verifier, expiresAt: row.expires_at }
510
+ }
511
+
512
+ // ── federation peers (TODO.federation/04) ────────────────────────────
513
+
514
+ import type { FederationPeer } from '../../store'
515
+
516
+ interface FederationPeerRow {
517
+ id: string
518
+ name: string
519
+ roles: string
520
+ descriptor_url: string | null
521
+ descriptor_json: string
522
+ pinned_via: 'url' | 'manual' | 'directory'
523
+ connectivity: 'verified' | 'unverified'
524
+ status: 'active' | 'revoked'
525
+ added_at: string
526
+ added_by: string | null
527
+ refreshed_at: string | null
528
+ revoked_at: string | null
529
+ revoked_by: string | null
530
+ }
531
+
532
+ function peerPayload(row: FederationPeerRow): FederationPeer {
533
+ return {
534
+ id: row.id,
535
+ name: row.name,
536
+ roles: row.roles,
537
+ descriptorUrl: row.descriptor_url,
538
+ descriptorJson: row.descriptor_json,
539
+ pinnedVia: row.pinned_via,
540
+ connectivity: row.connectivity,
541
+ status: row.status,
542
+ addedAt: row.added_at,
543
+ addedBy: row.added_by,
544
+ refreshedAt: row.refreshed_at,
545
+ revokedAt: row.revoked_at,
546
+ revokedBy: row.revoked_by,
547
+ }
548
+ }
549
+
550
+ export function listFederationPeers(status?: FederationPeer['status']): FederationPeer[] {
551
+ const rows = (status
552
+ ? getDb().prepare('SELECT * FROM federation_peers WHERE status = ? ORDER BY added_at').all(status)
553
+ : getDb().prepare('SELECT * FROM federation_peers ORDER BY added_at').all()) as FederationPeerRow[]
554
+ return rows.map(peerPayload)
555
+ }
556
+
557
+ export function getFederationPeer(id: string): FederationPeer | null {
558
+ const row = getDb().prepare('SELECT * FROM federation_peers WHERE id = ?').get(id) as FederationPeerRow | undefined
559
+ return row ? peerPayload(row) : null
560
+ }
561
+
562
+ export function upsertFederationPeer(input: {
563
+ id: string
564
+ name: string
565
+ roles: string
566
+ descriptorUrl: string | null
567
+ descriptorJson: string
568
+ pinnedVia: FederationPeer['pinnedVia']
569
+ connectivity: FederationPeer['connectivity']
570
+ addedBy: string | null
571
+ }): FederationPeer {
572
+ const db = getDb()
573
+ // The refresh path (an existing, ACTIVE row) re-pins the descriptor and
574
+ // stamps refreshed_at; a REVOKED peer is never resurrected by an upsert
575
+ // — re-adding a revoked peer is a deliberate new pin (delete-then-add
576
+ // is refused; the admin revokes, and a refresh of a revoked peer is
577
+ // refused by the route).
578
+ db.prepare(
579
+ `INSERT INTO federation_peers (id, name, roles, descriptor_url, descriptor_json, pinned_via, connectivity, added_by)
580
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
581
+ ON CONFLICT (id) DO UPDATE SET
582
+ name = excluded.name, roles = excluded.roles,
583
+ descriptor_url = excluded.descriptor_url, descriptor_json = excluded.descriptor_json,
584
+ pinned_via = excluded.pinned_via, connectivity = excluded.connectivity,
585
+ refreshed_at = datetime('now')`,
586
+ ).run(input.id, input.name, input.roles, input.descriptorUrl, input.descriptorJson, input.pinnedVia, input.connectivity, input.addedBy)
587
+ return getFederationPeer(input.id)!
588
+ }
589
+
590
+ export function revokeFederationPeer(id: string, revokedBy: string): FederationPeer | null {
591
+ const db = getDb()
592
+ db.prepare(
593
+ `UPDATE federation_peers SET status = 'revoked', revoked_at = datetime('now'), revoked_by = ? WHERE id = ?`,
594
+ ).run(revokedBy, id)
595
+ return getFederationPeer(id)
596
+ }
597
+
598
+ // ── User administration (TODO.federation/12 — multi-user instances) ──
599
+ // The instance's users are listed, created (the local/demo-provider
600
+ // path), reassigned and deactivated through the users API
601
+ // (server/routes/users.ts, gated by the users.manage permission).
602
+
603
+ import type { UserAdminRow } from '../../store'
604
+
605
+ function toAdminRow(user: any): UserAdminRow {
606
+ return {
607
+ id: user.id,
608
+ email: user.email,
609
+ name: user.name,
610
+ role: user.role,
611
+ roles: parseRoles(user.roles) ?? [user.role],
612
+ orgId: user.org_id ?? null,
613
+ active: user.active !== 0,
614
+ provider: user.provider,
615
+ lastLogin: user.last_login ?? null,
616
+ emailVerifiedAt: user.email_verified_at ?? null,
617
+ }
618
+ }
619
+
620
+ export function listUsers(): UserAdminRow[] {
621
+ const db = getDb()
622
+ return (db.prepare('SELECT * FROM users ORDER BY name').all() as any[]).map(toAdminRow)
623
+ }
624
+
625
+ /** Create a LOCAL user (the demo provider — the self-hosted instance's
626
+ * account path; OIDC-linked users arrive through item 10's linking and
627
+ * get roles assigned here the same way). Signs in with the instance's
628
+ * local password (DEMO_PASSWORD) — documented in docs/deployment/
629
+ * rbac.md. */
630
+ export function createLocalUser(input: {
631
+ email: string
632
+ name: string
633
+ role: string
634
+ roles?: string[]
635
+ orgId?: string | null
636
+ }): UserAdminRow {
637
+ const db = getDb()
638
+ const id = randomUUID()
639
+ const roles = input.roles?.length ? input.roles : [input.role]
640
+ db.prepare(
641
+ `INSERT INTO users (id, email, name, provider, provider_account_id, role, roles, org_id)
642
+ VALUES (?, ?, ?, 'demo', ?, ?, ?, ?)`,
643
+ ).run(id, input.email, input.name, input.email, input.role, JSON.stringify(roles), input.orgId ?? null)
644
+ if (input.orgId) syncPrimaryMembership(id) // TODO.identity/11 — the mirror
645
+ return toAdminRow(db.prepare('SELECT * FROM users WHERE id = ?').get(id))
646
+ }
647
+
648
+ /** Reassign a user's roles: `role` becomes the section-gating primary,
649
+ * `roles` the full permission set (validated against the instance's
650
+ * role map by the route — the store trusts its caller). */
651
+ export function setUserRoles(id: string, role: string, roles: string[]): boolean {
652
+ const db = getDb()
653
+ const res = db.prepare('UPDATE users SET role = ?, roles = ? WHERE id = ?')
654
+ .run(role, JSON.stringify(roles.length ? roles : [role]), id)
655
+ if (res.changes > 0) syncPrimaryMembership(id) // TODO.identity/11 — the mirror (a no-op for org-free accounts)
656
+ return res.changes > 0
657
+ }
658
+
659
+ /** Deactivate/reactivate: sessions stop resolving immediately (the
660
+ * getSessionUser join) and demo sign-in refuses. */
661
+ export function setUserActive(id: string, active: boolean): boolean {
662
+ const db = getDb()
663
+ const res = db.prepare('UPDATE users SET active = ? WHERE id = ?').run(active ? 1 : 0, id)
664
+ return res.changes > 0
665
+ }
666
+
667
+ // ── organization administration (TODO.identity/10) ───────────────────
668
+ // The self-service join requests: org-bound rows land with the org's
669
+ // admin; org_id NULL rows (the "not listed" path) land with BIML. The
670
+ // decision is atomic on status='pending'.
671
+
672
+ import type { OrgJoinRequest } from '../../store'
673
+
674
+ interface OrgJoinRequestRow {
675
+ id: string
676
+ name: string
677
+ email: string
678
+ org_id: string | null
679
+ org_name_text: string | null
680
+ requested_role: string
681
+ note: string | null
682
+ status: 'pending' | 'approved' | 'refused'
683
+ decided_by: string | null
684
+ decided_at: string | null
685
+ refusal_reason: string | null
686
+ invited_user_id: string | null
687
+ created_at: string
688
+ }
689
+
690
+ function joinRequestPayload(row: OrgJoinRequestRow): OrgJoinRequest {
691
+ return {
692
+ id: row.id,
693
+ name: row.name,
694
+ email: row.email,
695
+ orgId: row.org_id,
696
+ orgNameText: row.org_name_text,
697
+ requestedRole: row.requested_role,
698
+ note: row.note,
699
+ status: row.status,
700
+ decidedBy: row.decided_by,
701
+ decidedAt: row.decided_at,
702
+ refusalReason: row.refusal_reason,
703
+ invitedUserId: row.invited_user_id,
704
+ createdAt: row.created_at,
705
+ }
706
+ }
707
+
708
+ export function createOrgJoinRequest(input: {
709
+ name: string
710
+ email: string
711
+ orgId: string | null
712
+ orgNameText: string | null
713
+ requestedRole: string
714
+ note?: string | null
715
+ }): OrgJoinRequest {
716
+ const db = getDb()
717
+ const id = randomUUID()
718
+ db.prepare(
719
+ `INSERT INTO org_join_requests (id, name, email, org_id, org_name_text, requested_role, note)
720
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
721
+ ).run(id, input.name, input.email, input.orgId, input.orgNameText, input.requestedRole, input.note ?? null)
722
+ return joinRequestPayload(db.prepare('SELECT * FROM org_join_requests WHERE id = ?').get(id) as OrgJoinRequestRow)
723
+ }
724
+
725
+ export function getOrgJoinRequest(id: string): OrgJoinRequest | null {
726
+ const row = getDb().prepare('SELECT * FROM org_join_requests WHERE id = ?').get(id) as OrgJoinRequestRow | undefined
727
+ return row ? joinRequestPayload(row) : null
728
+ }
729
+
730
+ export function listOrgJoinRequests(filter?: {
731
+ scope?: 'org' | 'unregistered' | 'all'
732
+ orgId?: string
733
+ status?: OrgJoinRequest['status']
734
+ }): OrgJoinRequest[] {
735
+ const scope = filter?.scope ?? 'all'
736
+ const where: string[] = []
737
+ const args: unknown[] = []
738
+ if (scope === 'org') { where.push('org_id = ?'); args.push(filter?.orgId ?? '') }
739
+ if (scope === 'unregistered') where.push('org_id IS NULL')
740
+ if (filter?.status) { where.push('status = ?'); args.push(filter.status) }
741
+ const sql = `SELECT * FROM org_join_requests${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at`
742
+ const rows = getDb().prepare(sql).all(...args) as OrgJoinRequestRow[]
743
+ return rows.map(joinRequestPayload)
744
+ }
745
+
746
+ export function decideOrgJoinRequest(
747
+ id: string,
748
+ decision: {
749
+ status: 'approved' | 'refused'
750
+ decidedBy: string
751
+ refusalReason?: string | null
752
+ invitedUserId?: string | null
753
+ },
754
+ ): OrgJoinRequest | null {
755
+ const db = getDb()
756
+ // Atomic on 'pending' — a double decide (two admins, a resubmit) loses.
757
+ const res = db.prepare(
758
+ `UPDATE org_join_requests
759
+ SET status = ?, decided_by = ?, decided_at = datetime('now'), refusal_reason = ?, invited_user_id = ?
760
+ WHERE id = ? AND status = 'pending'`,
761
+ ).run(decision.status, decision.decidedBy, decision.refusalReason ?? null, decision.invitedUserId ?? null, id)
762
+ if (res.changes === 0) return null
763
+ return getOrgJoinRequest(id)
764
+ }
765
+
766
+ export function findPendingOrgJoinRequestByEmail(email: string): OrgJoinRequest | null {
767
+ const row = getDb().prepare(
768
+ `SELECT * FROM org_join_requests WHERE email = ? AND status = 'pending' ORDER BY created_at`,
769
+ ).get(email) as OrgJoinRequestRow | undefined
770
+ return row ? joinRequestPayload(row) : null
771
+ }
772
+
773
+ // ── organization memberships (TODO.identity/11 — the multi-org model) ──
774
+ // One row per (account, org): the per-org role set + the lifecycle
775
+ // state. THE DUAL-READ DOCTRINE: users.org_id/roles stay the
776
+ // backward-compatible read — the PRIMARY membership's mirror — until
777
+ // every consumer reads the memberships. syncPrimaryMembership (the
778
+ // mirror) rides every legacy writer above; setOrgMembershipRoles on a
779
+ // primary row writes the columns back. resolveOrgContext (the shared
780
+ // pure rule, ../../store) decides what a session/token actually acts
781
+ // AS; the columns' last writer never resurrects a disabled membership.
782
+
783
+ import type { OrgMembership, OrgMembershipState } from '../../store'
784
+ import { resolveOrgContext } from '../../store'
785
+
786
+ interface OrgMembershipRow {
787
+ id: string
788
+ user_id: string
789
+ org_id: string
790
+ roles: string
791
+ state: OrgMembershipState
792
+ is_primary: number
793
+ invited_by: string | null
794
+ created_at: string
795
+ activated_at: string | null
796
+ disabled_at: string | null
797
+ disabled_by: string | null
798
+ }
799
+
800
+ function membershipPayload(row: OrgMembershipRow): OrgMembership {
801
+ return {
802
+ id: row.id,
803
+ userId: row.user_id,
804
+ orgId: row.org_id,
805
+ roles: parseRoles(row.roles) ?? [],
806
+ state: row.state,
807
+ isPrimary: row.is_primary === 1,
808
+ invitedBy: row.invited_by,
809
+ createdAt: row.created_at,
810
+ activatedAt: row.activated_at,
811
+ disabledAt: row.disabled_at,
812
+ disabledBy: row.disabled_by,
813
+ }
814
+ }
815
+
816
+ /** THE MIRROR (the dual-read doctrine's write half): re-project the
817
+ * PRIMARY membership from the users row's legacy columns. Every legacy
818
+ * writer calls it after its update; an org-free account holds no
819
+ * primary membership. A DISABLED row keeps its state (the mirror never
820
+ * resurrects it — only roles + the primary mark move). Idempotent. */
821
+ export function syncPrimaryMembership(userId: string): void {
822
+ const db = getDb()
823
+ const user = db.prepare('SELECT id, role, roles, org_id FROM users WHERE id = ?').get(userId) as
824
+ { id: string; role: string; roles: string | null; org_id: string | null } | undefined
825
+ if (!user || !user.org_id) return
826
+ const roles = parseRoles(user.roles) ?? [user.role]
827
+ db.prepare('UPDATE org_memberships SET is_primary = 0 WHERE user_id = ? AND org_id != ? AND is_primary = 1')
828
+ .run(userId, user.org_id)
829
+ db.prepare(
830
+ `INSERT INTO org_memberships (id, user_id, org_id, roles, state, is_primary, activated_at)
831
+ VALUES (?, ?, ?, ?, 'active', 1, datetime('now'))
832
+ ON CONFLICT (user_id, org_id) DO UPDATE SET roles = excluded.roles, is_primary = 1`,
833
+ ).run(randomUUID(), userId, user.org_id, JSON.stringify(roles))
834
+ }
835
+
836
+ /** The session payload under the org context (getSessionUser's hook):
837
+ * the shared rule resolves the effective org + roles; a stale stamp
838
+ * (the membership was disabled or removed mid-session) is cleared on
839
+ * the read that notices it. */
840
+ function applySessionOrgContext(activeOrg: string | null, payload: AuthUserPayload): AuthUserPayload {
841
+ const active = activeOrg ? getOrgMembership(payload.id, activeOrg) : null
842
+ const primary = payload.orgId ? getOrgMembership(payload.id, payload.orgId) : null
843
+ const resolved = resolveOrgContext(payload, { activeOrg, active, primary })
844
+ if (activeOrg && !(active && active.state === 'active')) {
845
+ getDb().prepare('UPDATE sessions SET active_org = NULL WHERE user_id = ? AND active_org = ?').run(payload.id, activeOrg)
846
+ }
847
+ return { ...payload, orgId: resolved.orgId, roles: resolved.roles }
848
+ }
849
+
850
+ export function listOrgMemberships(userId: string): OrgMembership[] {
851
+ const rows = getDb().prepare(
852
+ 'SELECT * FROM org_memberships WHERE user_id = ? ORDER BY is_primary DESC, created_at',
853
+ ).all(userId) as OrgMembershipRow[]
854
+ return rows.map(membershipPayload)
855
+ }
856
+
857
+ export function listOrgMembers(orgId: string): OrgMembership[] {
858
+ const rows = getDb().prepare(
859
+ 'SELECT * FROM org_memberships WHERE org_id = ? ORDER BY created_at',
860
+ ).all(orgId) as OrgMembershipRow[]
861
+ return rows.map(membershipPayload)
862
+ }
863
+
864
+ export function getOrgMembership(userId: string, orgId: string): OrgMembership | null {
865
+ const row = getDb().prepare('SELECT * FROM org_memberships WHERE user_id = ? AND org_id = ?')
866
+ .get(userId, orgId) as OrgMembershipRow | undefined
867
+ return row ? membershipPayload(row) : null
868
+ }
869
+
870
+ /** Create the membership. NULL on the (user, org) conflict — the honest
871
+ * "already a member" (the route's 409). 'active' stamps activated_at;
872
+ * 'invited' waits for the holder's accept. */
873
+ export function createOrgMembership(input: {
874
+ userId: string
875
+ orgId: string
876
+ roles: string[]
877
+ state: OrgMembershipState
878
+ invitedBy?: string | null
879
+ }): OrgMembership | null {
880
+ const res = getDb().prepare(
881
+ `INSERT OR IGNORE INTO org_memberships (id, user_id, org_id, roles, state, invited_by, activated_at)
882
+ VALUES (?, ?, ?, ?, ?, ?, CASE WHEN ? = 'active' THEN datetime('now') ELSE NULL END)`,
883
+ ).run(
884
+ randomUUID(), input.userId, input.orgId, JSON.stringify(input.roles), input.state,
885
+ input.invitedBy ?? null, input.state,
886
+ )
887
+ if (res.changes === 0) return null
888
+ return getOrgMembership(input.userId, input.orgId)
889
+ }
890
+
891
+ /** Replace the per-org role set. The PRIMARY membership's write mirrors
892
+ * into the users row's roles (the dual-write; the section-gating
893
+ * primary role moves only when it fell out of the set). The route
894
+ * refuses an EMPTY set on the primary (the legacy columns carry no
895
+ * empty-set concept); the store's mirror writes the primary role in
896
+ * that case, keeping the columns honest. */
897
+ export function setOrgMembershipRoles(userId: string, orgId: string, roles: string[]): boolean {
898
+ const db = getDb()
899
+ const res = db.prepare('UPDATE org_memberships SET roles = ? WHERE user_id = ? AND org_id = ?')
900
+ .run(JSON.stringify(roles), userId, orgId)
901
+ if (res.changes === 0) return false
902
+ const membership = getOrgMembership(userId, orgId)
903
+ if (membership?.isPrimary) {
904
+ const user = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role: string } | undefined
905
+ if (user) {
906
+ const primaryRole = roles.includes(user.role) ? user.role : (roles[0] ?? user.role)
907
+ db.prepare('UPDATE users SET role = ?, roles = ? WHERE id = ?')
908
+ .run(primaryRole, JSON.stringify(roles.length ? roles : [primaryRole]), userId)
909
+ }
910
+ }
911
+ return true
912
+ }
913
+
914
+ /** The lifecycle act. Disabling also ends the live context: every
915
+ * session stamped with the org falls back to the primary context (the
916
+ * stamp is cleared here, and any in-flight OIDC code's context is
917
+ * re-judged against the live membership at the exchange). */
918
+ export function setOrgMembershipState(
919
+ userId: string,
920
+ orgId: string,
921
+ state: OrgMembershipState,
922
+ actor?: string | null,
923
+ ): OrgMembership | null {
924
+ const db = getDb()
925
+ const existing = getOrgMembership(userId, orgId)
926
+ if (!existing) return null
927
+ if (state === 'active') {
928
+ // Re-activation clears the disable stamps (the row reads honestly).
929
+ db.prepare("UPDATE org_memberships SET state = 'active', activated_at = datetime('now'), disabled_at = NULL, disabled_by = NULL WHERE user_id = ? AND org_id = ?")
930
+ .run(userId, orgId)
931
+ } else if (state === 'disabled') {
932
+ db.prepare("UPDATE org_memberships SET state = 'disabled', disabled_at = datetime('now'), disabled_by = ? WHERE user_id = ? AND org_id = ?")
933
+ .run(actor ?? null, userId, orgId)
934
+ db.prepare('UPDATE sessions SET active_org = NULL WHERE user_id = ? AND active_org = ?').run(userId, orgId)
935
+ } else {
936
+ db.prepare("UPDATE org_memberships SET state = 'invited' WHERE user_id = ? AND org_id = ?").run(userId, orgId)
937
+ }
938
+ return getOrgMembership(userId, orgId)
939
+ }
940
+
941
+ /** Remove the row (the holder declining an invitation; the erasure's
942
+ * cleanup). The route refuses the PRIMARY membership. */
943
+ export function deleteOrgMembership(userId: string, orgId: string): boolean {
944
+ const db = getDb()
945
+ const res = db.prepare('DELETE FROM org_memberships WHERE user_id = ? AND org_id = ?').run(userId, orgId)
946
+ if (res.changes > 0) {
947
+ db.prepare('UPDATE sessions SET active_org = NULL WHERE user_id = ? AND active_org = ?').run(userId, orgId)
948
+ }
949
+ return res.changes > 0
950
+ }
951
+
952
+ /** The session's stamped active-org context (NULL = the primary
953
+ * context; also NULL for an unknown/expired token). */
954
+ export function getSessionActiveOrg(token: string): string | null {
955
+ const row = getDb().prepare("SELECT active_org FROM sessions WHERE token = ? AND expires_at > datetime('now')")
956
+ .get(token) as { active_org: string | null } | undefined
957
+ return row?.active_org ?? null
958
+ }
959
+
960
+ /** Stamp the session's active-org context (the route validated the
961
+ * membership first); NULL clears to the primary context. */
962
+ export function setSessionActiveOrg(token: string, orgId: string | null): boolean {
963
+ const res = getDb().prepare("UPDATE sessions SET active_org = ? WHERE token = ? AND expires_at > datetime('now')")
964
+ .run(orgId, token)
965
+ return res.changes > 0
966
+ }
967
+
968
+ // ── the organization registry (TODO.identity-features/05) ────────────
969
+ // The identity service's OWN org registry: the first-class organizations
970
+ // the membership graph references by id. The lifecycle acts are the
971
+ // routes' (the disable cascade loops setOrgMembershipState); these are
972
+ // the row reads/writes.
973
+
974
+ import type { OrgRegistryContact, OrgRegistryOrg, OrgRegistryState } from '../../store'
975
+
976
+ interface OrgRegistryRow {
977
+ id: string
978
+ name: string
979
+ short_name: string | null
980
+ kind: string | null
981
+ country: string | null
982
+ contacts: string
983
+ participant_ref: string | null
984
+ state: OrgRegistryState
985
+ created_at: string
986
+ created_by: string | null
987
+ updated_at: string | null
988
+ updated_by: string | null
989
+ disabled_at: string | null
990
+ disabled_by: string | null
991
+ }
992
+
993
+ /** The contacts column's defensive parse: a malformed row entry is
994
+ * skipped, never trusted (an entry is a contact only with its email). */
995
+ export function parseOrgContacts(json: string | null): OrgRegistryContact[] {
996
+ if (!json) return []
997
+ try {
998
+ const parsed = JSON.parse(json) as unknown
999
+ if (!Array.isArray(parsed)) return []
1000
+ return parsed
1001
+ .filter((e): e is Record<string, unknown> => !!e && typeof e === 'object')
1002
+ .map(e => ({ name: typeof e.name === 'string' && e.name.trim() ? e.name.trim() : null, email: typeof e.email === 'string' ? e.email.trim() : '' }))
1003
+ .filter(e => e.email.includes('@'))
1004
+ } catch {
1005
+ return []
1006
+ }
1007
+ }
1008
+
1009
+ function orgRegistryPayload(row: OrgRegistryRow): OrgRegistryOrg {
1010
+ return {
1011
+ id: row.id,
1012
+ name: row.name,
1013
+ shortName: row.short_name,
1014
+ kind: row.kind,
1015
+ country: row.country,
1016
+ contacts: parseOrgContacts(row.contacts),
1017
+ participantRef: row.participant_ref,
1018
+ state: row.state,
1019
+ createdAt: row.created_at,
1020
+ createdBy: row.created_by,
1021
+ updatedAt: row.updated_at,
1022
+ updatedBy: row.updated_by,
1023
+ disabledAt: row.disabled_at,
1024
+ disabledBy: row.disabled_by,
1025
+ }
1026
+ }
1027
+
1028
+ export function listOrgRegistryOrgs(): OrgRegistryOrg[] {
1029
+ const rows = getDb().prepare('SELECT * FROM org_registry').all() as OrgRegistryRow[]
1030
+ return rows.map(orgRegistryPayload).sort((a, b) => a.name.localeCompare(b.name))
1031
+ }
1032
+
1033
+ export function getOrgRegistryOrg(id: string): OrgRegistryOrg | null {
1034
+ const row = getDb().prepare('SELECT * FROM org_registry WHERE id = ?').get(id) as OrgRegistryRow | undefined
1035
+ return row ? orgRegistryPayload(row) : null
1036
+ }
1037
+
1038
+ /** Add the organization; NULL on the id conflict (the slug is taken). */
1039
+ export function createOrgRegistryOrg(input: {
1040
+ id: string
1041
+ name: string
1042
+ shortName?: string | null
1043
+ kind?: string | null
1044
+ country?: string | null
1045
+ contacts?: OrgRegistryContact[]
1046
+ participantRef?: string | null
1047
+ createdBy?: string | null
1048
+ }): OrgRegistryOrg | null {
1049
+ const res = getDb().prepare(
1050
+ `INSERT OR IGNORE INTO org_registry (id, name, short_name, kind, country, contacts, participant_ref, created_by)
1051
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1052
+ ).run(
1053
+ input.id, input.name, input.shortName ?? null, input.kind ?? null, input.country ?? null,
1054
+ JSON.stringify(input.contacts ?? []), input.participantRef ?? null, input.createdBy ?? null,
1055
+ )
1056
+ if (res.changes === 0) return null
1057
+ return getOrgRegistryOrg(input.id)
1058
+ }
1059
+
1060
+ /** Edit the display data (the id never moves); stamps updated_at/by.
1061
+ * NULL when the registry does not carry the org. */
1062
+ export function updateOrgRegistryOrg(
1063
+ id: string,
1064
+ patch: {
1065
+ name?: string
1066
+ shortName?: string | null
1067
+ kind?: string | null
1068
+ country?: string | null
1069
+ contacts?: OrgRegistryContact[]
1070
+ participantRef?: string | null
1071
+ },
1072
+ actor?: string | null,
1073
+ ): OrgRegistryOrg | null {
1074
+ const sets: string[] = []
1075
+ const params: unknown[] = []
1076
+ if (patch.name !== undefined) { sets.push('name = ?'); params.push(patch.name) }
1077
+ if (patch.shortName !== undefined) { sets.push('short_name = ?'); params.push(patch.shortName) }
1078
+ if (patch.kind !== undefined) { sets.push('kind = ?'); params.push(patch.kind) }
1079
+ if (patch.country !== undefined) { sets.push('country = ?'); params.push(patch.country) }
1080
+ if (patch.contacts !== undefined) { sets.push('contacts = ?'); params.push(JSON.stringify(patch.contacts)) }
1081
+ if (patch.participantRef !== undefined) { sets.push('participant_ref = ?'); params.push(patch.participantRef) }
1082
+ sets.push("updated_at = datetime('now')", 'updated_by = ?')
1083
+ params.push(actor ?? null)
1084
+ const res = getDb().prepare(`UPDATE org_registry SET ${sets.join(', ')} WHERE id = ?`).run(...params, id)
1085
+ if (res.changes === 0) return null
1086
+ return getOrgRegistryOrg(id)
1087
+ }
1088
+
1089
+ /** The lifecycle act: disable stamps disabled_at/by; re-enable clears
1090
+ * them (the memberships stay as they are — re-activation is the
1091
+ * per-membership deliberate act). NULL when the org is unknown. */
1092
+ export function setOrgRegistryOrgState(id: string, state: OrgRegistryState, actor?: string | null): OrgRegistryOrg | null {
1093
+ const existing = getOrgRegistryOrg(id)
1094
+ if (!existing) return null
1095
+ if (state === 'disabled') {
1096
+ getDb().prepare("UPDATE org_registry SET state = 'disabled', disabled_at = datetime('now'), disabled_by = ? WHERE id = ?")
1097
+ .run(actor ?? null, id)
1098
+ } else {
1099
+ getDb().prepare("UPDATE org_registry SET state = 'active', disabled_at = NULL, disabled_by = NULL WHERE id = ?")
1100
+ .run(id)
1101
+ }
1102
+ return getOrgRegistryOrg(id)
1103
+ }
1104
+
1105
+ /** The erasure-adjacent hard delete (the route guards it: an org that
1106
+ * ever held a membership, or that a join request references, disables
1107
+ * instead). */
1108
+ export function deleteOrgRegistryOrg(id: string): boolean {
1109
+ const res = getDb().prepare('DELETE FROM org_registry WHERE id = ?').run(id)
1110
+ return res.changes > 0
1111
+ }
1112
+
1113
+ // ── the register's holder-org attribution (TODO.register/02) ─────────
1114
+ // The certificate_holder_orgs / certificate_holder_claims tables (the
1115
+ // 0015 migration): which OP org a registered certificate belongs to (the
1116
+ // hub's own record), and the legacy-row claim act's state machine.
1117
+
1118
+ import type { CertificateHolderClaim, CertificateHolderOrg } from '../../store'
1119
+
1120
+ interface HolderOrgRow {
1121
+ certificate_id: string
1122
+ org_id: string
1123
+ org_name: string
1124
+ source: string
1125
+ attributed_at: string
1126
+ attributed_by: string | null
1127
+ claim_id: string | null
1128
+ }
1129
+
1130
+ function holderOrgPayload(row: HolderOrgRow): CertificateHolderOrg {
1131
+ return {
1132
+ certificateId: row.certificate_id,
1133
+ orgId: row.org_id,
1134
+ orgName: row.org_name,
1135
+ source: row.source as CertificateHolderOrg['source'],
1136
+ attributedAt: row.attributed_at,
1137
+ attributedBy: row.attributed_by,
1138
+ claimId: row.claim_id,
1139
+ }
1140
+ }
1141
+
1142
+ /** INSERT-IF-ABSENT: the first attribution wins. NULL when the
1143
+ * certificate already carries one (never a silent overwrite). */
1144
+ export function attributeCertificateHolderOrg(input: {
1145
+ certificateId: string
1146
+ orgId: string
1147
+ orgName: string
1148
+ source: CertificateHolderOrg['source']
1149
+ attributedAt: string
1150
+ attributedBy?: string | null
1151
+ claimId?: string | null
1152
+ }): CertificateHolderOrg | null {
1153
+ const res = getDb().prepare(
1154
+ `INSERT OR IGNORE INTO certificate_holder_orgs
1155
+ (certificate_id, org_id, org_name, source, attributed_at, attributed_by, claim_id)
1156
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
1157
+ ).run(
1158
+ input.certificateId, input.orgId, input.orgName, input.source,
1159
+ input.attributedAt, input.attributedBy ?? null, input.claimId ?? null,
1160
+ )
1161
+ if (res.changes === 0) return null
1162
+ return getCertificateHolderOrg(input.certificateId)
1163
+ }
1164
+
1165
+ export function getCertificateHolderOrg(certificateId: string): CertificateHolderOrg | null {
1166
+ const row = getDb().prepare('SELECT * FROM certificate_holder_orgs WHERE certificate_id = ?')
1167
+ .get(certificateId) as HolderOrgRow | undefined
1168
+ return row ? holderOrgPayload(row) : null
1169
+ }
1170
+
1171
+ export function listCertificateHolderOrgs(filter?: { orgId?: string }): CertificateHolderOrg[] {
1172
+ const rows = (filter?.orgId
1173
+ ? getDb().prepare('SELECT * FROM certificate_holder_orgs WHERE org_id = ?').all(filter.orgId)
1174
+ : getDb().prepare('SELECT * FROM certificate_holder_orgs').all()) as HolderOrgRow[]
1175
+ return rows.map(holderOrgPayload)
1176
+ }
1177
+
1178
+ interface HolderClaimRow {
1179
+ id: string
1180
+ certificate_id: string
1181
+ claimant_org_id: string
1182
+ claimant_org_name: string
1183
+ matched_holder_name: string
1184
+ claimed_by: string
1185
+ state: string
1186
+ decided_by: string | null
1187
+ decided_at: string | null
1188
+ refusal_reason: string | null
1189
+ created_at: string
1190
+ }
1191
+
1192
+ function holderClaimPayload(row: HolderClaimRow): CertificateHolderClaim {
1193
+ return {
1194
+ id: row.id,
1195
+ certificateId: row.certificate_id,
1196
+ claimantOrgId: row.claimant_org_id,
1197
+ claimantOrgName: row.claimant_org_name,
1198
+ matchedHolderName: row.matched_holder_name,
1199
+ claimedBy: row.claimed_by,
1200
+ state: row.state as CertificateHolderClaim['state'],
1201
+ decidedBy: row.decided_by,
1202
+ decidedAt: row.decided_at,
1203
+ refusalReason: row.refusal_reason,
1204
+ createdAt: row.created_at,
1205
+ }
1206
+ }
1207
+
1208
+ export function createCertificateHolderClaim(input: {
1209
+ certificateId: string
1210
+ claimantOrgId: string
1211
+ claimantOrgName: string
1212
+ matchedHolderName: string
1213
+ claimedBy: string
1214
+ }): CertificateHolderClaim {
1215
+ const id = randomUUID()
1216
+ getDb().prepare(
1217
+ `INSERT INTO certificate_holder_claims
1218
+ (id, certificate_id, claimant_org_id, claimant_org_name, matched_holder_name, claimed_by)
1219
+ VALUES (?, ?, ?, ?, ?, ?)`,
1220
+ ).run(id, input.certificateId, input.claimantOrgId, input.claimantOrgName, input.matchedHolderName, input.claimedBy)
1221
+ return getCertificateHolderClaim(id)!
1222
+ }
1223
+
1224
+ export function getCertificateHolderClaim(id: string): CertificateHolderClaim | null {
1225
+ const row = getDb().prepare('SELECT * FROM certificate_holder_claims WHERE id = ?')
1226
+ .get(id) as HolderClaimRow | undefined
1227
+ return row ? holderClaimPayload(row) : null
1228
+ }
1229
+
1230
+ export function listCertificateHolderClaims(filter?: {
1231
+ state?: CertificateHolderClaim['state']
1232
+ claimantOrgId?: string
1233
+ certificateId?: string
1234
+ }): CertificateHolderClaim[] {
1235
+ const where: string[] = []
1236
+ const args: unknown[] = []
1237
+ if (filter?.state) { where.push('state = ?'); args.push(filter.state) }
1238
+ if (filter?.claimantOrgId) { where.push('claimant_org_id = ?'); args.push(filter.claimantOrgId) }
1239
+ if (filter?.certificateId) { where.push('certificate_id = ?'); args.push(filter.certificateId) }
1240
+ const sql = `SELECT * FROM certificate_holder_claims${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at`
1241
+ const rows = getDb().prepare(sql).all(...args) as HolderClaimRow[]
1242
+ return rows.map(holderClaimPayload)
1243
+ }
1244
+
1245
+ /** The estate admin's decision, ATOMIC on 'pending' — a double decision
1246
+ * loses the race and answers null. */
1247
+ export function decideCertificateHolderClaim(
1248
+ id: string,
1249
+ decision: { status: 'confirmed' | 'refused'; decidedBy: string; refusalReason?: string | null },
1250
+ ): CertificateHolderClaim | null {
1251
+ const res = getDb().prepare(
1252
+ `UPDATE certificate_holder_claims
1253
+ SET state = ?, decided_by = ?, decided_at = datetime('now'), refusal_reason = ?
1254
+ WHERE id = ? AND state = 'pending'`,
1255
+ ).run(decision.status, decision.decidedBy, decision.refusalReason ?? null, id)
1256
+ if (res.changes === 0) return null
1257
+ return getCertificateHolderClaim(id)
1258
+ }
1259
+
1260
+ export function findPendingCertificateHolderClaim(certificateId: string): CertificateHolderClaim | null {
1261
+ const row = getDb().prepare(
1262
+ "SELECT * FROM certificate_holder_claims WHERE certificate_id = ? AND state = 'pending'",
1263
+ ).get(certificateId) as HolderClaimRow | undefined
1264
+ return row ? holderClaimPayload(row) : null
1265
+ }
1266
+
1267
+ // ── the instrument register (TODO.register/03) ─────────────────────────
1268
+ // The platform-side serial register: one row per instrument registered
1269
+ // under a type certificate's scope. The scope check + the cones are the
1270
+ // route's (browser/server/routes/registrations.ts); these are the row
1271
+ // reads/writes. Every row returned is a registration that STOOD — the
1272
+ // refused (out-of-scope) declaration never lands.
1273
+
1274
+ import type {
1275
+ InstrumentRegistration,
1276
+ InstrumentRegistrationLifecycle,
1277
+ InstrumentRegistrationScopeStatus,
1278
+ } from '../../store'
1279
+
1280
+ interface InstrumentRegistrationRow {
1281
+ id: string
1282
+ certificate_id: string
1283
+ holder_org_id: string
1284
+ standard_id: string
1285
+ serial_number: string
1286
+ manufacture_date: string | null
1287
+ designations: string
1288
+ scope_status: InstrumentRegistrationScopeStatus
1289
+ scope_detail: string | null
1290
+ lifecycle: InstrumentRegistrationLifecycle
1291
+ registered_at: string
1292
+ registered_by: string | null
1293
+ updated_at: string | null
1294
+ updated_by: string | null
1295
+ }
1296
+
1297
+ /** The designations column's defensive parse: a malformed cell reads as
1298
+ * the empty object, never trusted (the parseOrgContacts posture). */
1299
+ function parseDesignations(json: string | null): Record<string, unknown> {
1300
+ if (!json) return {}
1301
+ try {
1302
+ const parsed = JSON.parse(json) as unknown
1303
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {}
1304
+ } catch {
1305
+ return {}
1306
+ }
1307
+ }
1308
+
1309
+ function instrumentRegistrationPayload(row: InstrumentRegistrationRow): InstrumentRegistration {
1310
+ return {
1311
+ id: row.id,
1312
+ certificateId: row.certificate_id,
1313
+ holderOrgId: row.holder_org_id,
1314
+ standardId: row.standard_id,
1315
+ serialNumber: row.serial_number,
1316
+ manufactureDate: row.manufacture_date,
1317
+ designations: parseDesignations(row.designations),
1318
+ scopeStatus: row.scope_status,
1319
+ scopeDetail: row.scope_detail,
1320
+ lifecycle: row.lifecycle,
1321
+ registeredAt: row.registered_at,
1322
+ registeredBy: row.registered_by,
1323
+ updatedAt: row.updated_at,
1324
+ updatedBy: row.updated_by,
1325
+ }
1326
+ }
1327
+
1328
+ export function listInstrumentRegistrations(): InstrumentRegistration[] {
1329
+ const rows = getDb().prepare('SELECT * FROM instrument_registrations').all() as InstrumentRegistrationRow[]
1330
+ return rows
1331
+ .map(instrumentRegistrationPayload)
1332
+ .sort((a, b) => a.certificateId.localeCompare(b.certificateId) || a.serialNumber.localeCompare(b.serialNumber))
1333
+ }
1334
+
1335
+ export function listInstrumentRegistrationsForCertificate(certificateId: string): InstrumentRegistration[] {
1336
+ const rows = getDb().prepare('SELECT * FROM instrument_registrations WHERE certificate_id = ?').all(certificateId) as InstrumentRegistrationRow[]
1337
+ return rows.map(instrumentRegistrationPayload).sort((a, b) => a.serialNumber.localeCompare(b.serialNumber))
1338
+ }
1339
+
1340
+ export function listInstrumentRegistrationsForHolder(holderOrgId: string): InstrumentRegistration[] {
1341
+ const rows = getDb().prepare('SELECT * FROM instrument_registrations WHERE holder_org_id = ?').all(holderOrgId) as InstrumentRegistrationRow[]
1342
+ return rows.map(instrumentRegistrationPayload).sort((a, b) => a.serialNumber.localeCompare(b.serialNumber))
1343
+ }
1344
+
1345
+ export function getInstrumentRegistration(id: string): InstrumentRegistration | null {
1346
+ const row = getDb().prepare('SELECT * FROM instrument_registrations WHERE id = ?').get(id) as InstrumentRegistrationRow | undefined
1347
+ return row ? instrumentRegistrationPayload(row) : null
1348
+ }
1349
+
1350
+ /** Register the instrument; NULL on the (certificate, serial) conflict
1351
+ * (the same physical unit never registers twice under one certificate —
1352
+ * the route's honest 409). */
1353
+ export function createInstrumentRegistration(input: {
1354
+ id: string
1355
+ certificateId: string
1356
+ holderOrgId: string
1357
+ standardId: string
1358
+ serialNumber: string
1359
+ manufactureDate?: string | null
1360
+ designations?: Record<string, unknown>
1361
+ scopeStatus: InstrumentRegistrationScopeStatus
1362
+ scopeDetail?: string | null
1363
+ registeredBy?: string | null
1364
+ }): InstrumentRegistration | null {
1365
+ const res = getDb().prepare(
1366
+ `INSERT OR IGNORE INTO instrument_registrations
1367
+ (id, certificate_id, holder_org_id, standard_id, serial_number, manufacture_date, designations, scope_status, scope_detail, registered_by)
1368
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1369
+ ).run(
1370
+ input.id, input.certificateId, input.holderOrgId, input.standardId, input.serialNumber,
1371
+ input.manufactureDate ?? null, JSON.stringify(input.designations ?? {}),
1372
+ input.scopeStatus, input.scopeDetail ?? null, input.registeredBy ?? null,
1373
+ )
1374
+ if (res.changes === 0) return null
1375
+ return getInstrumentRegistration(input.id)
1376
+ }
1377
+
1378
+ /** The lifecycle act (the transition rule is the route's); stamps
1379
+ * updated_at/by. NULL when the register does not carry the id. */
1380
+ export function setInstrumentRegistrationLifecycle(
1381
+ id: string,
1382
+ lifecycle: InstrumentRegistrationLifecycle,
1383
+ actor?: string | null,
1384
+ ): InstrumentRegistration | null {
1385
+ const res = getDb().prepare(
1386
+ "UPDATE instrument_registrations SET lifecycle = ?, updated_at = datetime('now'), updated_by = ? WHERE id = ?",
1387
+ ).run(lifecycle, actor ?? null, id)
1388
+ if (res.changes === 0) return null
1389
+ return getInstrumentRegistration(id)
1390
+ }