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