@oimlsmart/platform-server 0.1.8 → 0.2.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 +8 -2
- package/package.json +1 -1
- package/src/store/d1.ts +531 -316
- package/src/store/sqlite/store.ts +7 -0
- package/src/store/sqlite.ts +4 -0
- package/src/store.ts +37 -0
package/README.md
CHANGED
|
@@ -11,8 +11,14 @@ pin IS the contract (TODO.repos/01).
|
|
|
11
11
|
## What it is
|
|
12
12
|
|
|
13
13
|
- `store`, the ServerStore seam: the interface, every row type, the
|
|
14
|
-
demo-account plan, `installStore`/`getStore
|
|
15
|
-
|
|
14
|
+
demo-account plan, `installStore`/`getStore`, and `StoreUnavailable`
|
|
15
|
+
(the bounded writes' honest timeout error). Worker-safe.
|
|
16
|
+
- `store/d1`, the Cloudflare D1 implementation: every write/batch/DDL
|
|
17
|
+
statement races a confirmation budget (`DEFAULT_STORE_WRITE_BUDGET_MS`
|
|
18
|
+
= 5 s; the consumer's `STORE_WRITE_BUDGET_MS` env binding retunes it
|
|
19
|
+
through `resolveStoreWriteBudgetMs` + the `d1StoreFor`/`D1ServerStore`
|
|
20
|
+
options) — a timeout throws the seam's `StoreUnavailable`. Reads stay
|
|
21
|
+
unbounded. Worker-safe.
|
|
16
22
|
- `store/sqlite`, the node implementation (better-sqlite3): the server
|
|
17
23
|
store composed from the sync modules, the entity-store verbs, the raw
|
|
18
24
|
driver handle, the schema (`SQLITE_SCHEMA_PATH`), and the migration
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oimlsmart/platform-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The OIML SMART platform server kernel: the store seam (ServerStore + the D1 and SQLite implementations), the canonical D1 migration set both deployments apply, the instance profile, the mailer, the RBAC map, the OIDC/OAuth client cones, and the shared role/permission vocabulary. Consumed by the smart monorepo (browser/) and the identity service.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/store/d1.ts
CHANGED
|
@@ -16,11 +16,22 @@
|
|
|
16
16
|
// d1-store.test.ts runs this class against a better-sqlite3-backed D1
|
|
17
17
|
// facade (the binding contract with real semantics) plus the real
|
|
18
18
|
// workerd binding through scripts/cloudflare-smoke.ts there.
|
|
19
|
+
//
|
|
20
|
+
// THE BOUNDED-WRITE DISCIPLINE (the 2026-09-01 outage's lesson): every
|
|
21
|
+
// write/batch/DDL statement races a confirmation budget (the default
|
|
22
|
+
// DEFAULT_STORE_WRITE_BUDGET_MS, tunable per deployment through the
|
|
23
|
+
// consumer's STORE_WRITE_BUDGET_MS env binding) — a slow/hung store
|
|
24
|
+
// write answers StoreUnavailable in seconds, never spins the request
|
|
25
|
+
// forever. READS stay unbounded here: the read-path latency story is
|
|
26
|
+
// read replication, not this bound. The constructor wraps the binding
|
|
27
|
+
// in the bounded facade ONCE (boundedD1Writes below); the ensure memos
|
|
28
|
+
// key on the RAW binding (a rejected chain still evicts itself).
|
|
19
29
|
// ═══════════════════════════════════════════════════════════════════
|
|
20
30
|
|
|
21
31
|
import type { D1Database, D1PreparedStatement } from '@cloudflare/workers-types'
|
|
22
32
|
import {
|
|
23
33
|
DEMO_PASSWORD,
|
|
34
|
+
StoreUnavailable,
|
|
24
35
|
type AccountEmail,
|
|
25
36
|
type AddAccountEmailResult,
|
|
26
37
|
type AdvanceCounterResult,
|
|
@@ -188,8 +199,226 @@ function toAdminRow(user: UserRecord & { last_login?: string | null; provider?:
|
|
|
188
199
|
* store wipes alongside (its rows reference the wiped events). */
|
|
189
200
|
const WIPE_TABLES = ['entity_changes', 'evidence_records', 'entities', 'events', 'instrument_registrations', 'notify_deliveries'] as const
|
|
190
201
|
|
|
202
|
+
// ── The ensure chains' memo scope (the 2026-09 portal-load audit, R2) ──
|
|
203
|
+
// Every defensive ensure below (a dev-database heal: the PRAGMA probes,
|
|
204
|
+
// the CREATE … IF NOT EXISTS, the idempotent membership backfill) was
|
|
205
|
+
// memoized on the store INSTANCE — but the Worker consumers install a
|
|
206
|
+
// store per request (the binding factory memoizes per binding OBJECT and
|
|
207
|
+
// the Workers runtime hands each request its own env, so the binding's
|
|
208
|
+
// identity is not guaranteed to recur), and the audit measured the
|
|
209
|
+
// ~13-statement chain on every authenticated request (~0.6–0.9 s on the
|
|
210
|
+
// demo hub's D1). The ensures are idempotent by construction, so the
|
|
211
|
+
// honest scope is the (binding, chain) pair held at MODULE scope: the
|
|
212
|
+
// Worker's isolate persists across requests and the consumer pins the
|
|
213
|
+
// binding (the smart repo's server/cloudflare.ts), so the same
|
|
214
|
+
// D1Database object recurs and the chain runs once per isolate. A
|
|
215
|
+
// consumer whose runtime rotates binding objects degrades to the old
|
|
216
|
+
// per-request posture — correct, just unmemoized. A REJECTED ensure
|
|
217
|
+
// evicts itself: the next call retries the heal.
|
|
218
|
+
interface EnsureMemos {
|
|
219
|
+
usersColumns: Promise<void> | null
|
|
220
|
+
sessionColumns: Promise<void> | null
|
|
221
|
+
membershipSupport: Promise<void> | null
|
|
222
|
+
orgRegistrySupport: Promise<void> | null
|
|
223
|
+
holderAttributionSupport: Promise<void> | null
|
|
224
|
+
instrumentRegistrationSupport: Promise<void> | null
|
|
225
|
+
oidcColumns: Promise<void> | null
|
|
226
|
+
personalAccessTokenSupport: Promise<void> | null
|
|
227
|
+
consentGrantSupport: Promise<void> | null
|
|
228
|
+
accountEmailSupport: Promise<void> | null
|
|
229
|
+
notifyDeliverySupport: Promise<void> | null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const ensureMemosByBinding = new WeakMap<D1Database, EnsureMemos>()
|
|
233
|
+
|
|
234
|
+
/** Run the chain once per (binding, slot); a rejection evicts itself so
|
|
235
|
+
* the next caller retries. */
|
|
236
|
+
function ensured(binding: D1Database, slot: keyof EnsureMemos, run: () => Promise<void>): Promise<void> {
|
|
237
|
+
let memos = ensureMemosByBinding.get(binding)
|
|
238
|
+
if (!memos) {
|
|
239
|
+
memos = {
|
|
240
|
+
usersColumns: null, sessionColumns: null, membershipSupport: null,
|
|
241
|
+
orgRegistrySupport: null, holderAttributionSupport: null,
|
|
242
|
+
instrumentRegistrationSupport: null, oidcColumns: null,
|
|
243
|
+
personalAccessTokenSupport: null, consentGrantSupport: null,
|
|
244
|
+
accountEmailSupport: null, notifyDeliverySupport: null,
|
|
245
|
+
}
|
|
246
|
+
ensureMemosByBinding.set(binding, memos)
|
|
247
|
+
}
|
|
248
|
+
let pending = memos[slot]
|
|
249
|
+
if (!pending) {
|
|
250
|
+
pending = run()
|
|
251
|
+
memos[slot] = pending
|
|
252
|
+
pending.catch(() => { if (memos[slot] === pending) memos[slot] = null })
|
|
253
|
+
}
|
|
254
|
+
return pending
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// TODO.identity/06's last-active stamp (getSessionUser's write): the
|
|
258
|
+
// DB-side 60 s WHERE clause stays the source of truth (a second
|
|
259
|
+
// isolate's write still lands through it); the in-isolate cache below
|
|
260
|
+
// simply never ISSUES the write the row would refuse — the audit's R2
|
|
261
|
+
// measured that write on every authenticated request. Keyed on the
|
|
262
|
+
// session token (per-isolate), capped so a long-lived isolate's map
|
|
263
|
+
// stays bounded (a cap reset only re-issues a write the DB clause then
|
|
264
|
+
// throttles — never a correctness change).
|
|
265
|
+
const lastSeenWrites = new Map<string, number>()
|
|
266
|
+
const LAST_SEEN_THROTTLE_MS = 60_000
|
|
267
|
+
const LAST_SEEN_CACHE_CAP = 4096
|
|
268
|
+
|
|
269
|
+
// ── The bounded-write discipline (the 2026-09-01 outage's lesson) ────
|
|
270
|
+
// Yesterday's outage: a cross-region path flap hung the identity
|
|
271
|
+
// service's D1 WRITES indefinitely and the login page spun for minutes.
|
|
272
|
+
// The discipline: every write/batch/DDL statement races a confirmation
|
|
273
|
+
// budget and a timeout answers StoreUnavailable — an honest error in
|
|
274
|
+
// seconds, never an infinite spin. READS (SELECT/PRAGMA/…) pass through
|
|
275
|
+
// untouched: the read-path latency story is read replication, a
|
|
276
|
+
// separate fix. The bound wraps the BINDING once at construction (the
|
|
277
|
+
// store's every write path — the 180+ .run() sites, the batches, the
|
|
278
|
+
// ensure chains' DDL — flows through it), so no call site changes.
|
|
279
|
+
|
|
280
|
+
/** The default write-confirmation budget: D1's healthy write latency is
|
|
281
|
+
* tens of ms; 5 s is two orders of magnitude past any honest p99 yet
|
|
282
|
+
* still "seconds" to a waiting human — the 503 reaches the login page
|
|
283
|
+
* before the browser's own patience ends. Tunable per deployment (the
|
|
284
|
+
* consumer's STORE_WRITE_BUDGET_MS env binding). */
|
|
285
|
+
export const DEFAULT_STORE_WRITE_BUDGET_MS = 5_000
|
|
286
|
+
|
|
287
|
+
export interface D1WriteBudgetOptions {
|
|
288
|
+
/** The write/batch/DDL confirmation budget in ms. Absent:
|
|
289
|
+
* DEFAULT_STORE_WRITE_BUDGET_MS. */
|
|
290
|
+
writeBudgetMs?: number
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The consumer-side env resolution (the resolveInstanceProfileFromEnv
|
|
294
|
+
* posture — the kernel never reads process.env; the Worker-safe
|
|
295
|
+
* modules take the env binding as an argument). Unset/garbage resolves
|
|
296
|
+
* undefined — the store's default stands, never a silent 0. */
|
|
297
|
+
export function resolveStoreWriteBudgetMs(env?: { STORE_WRITE_BUDGET_MS?: string }): number | undefined {
|
|
298
|
+
const raw = env?.STORE_WRITE_BUDGET_MS?.trim()
|
|
299
|
+
if (!raw) return undefined
|
|
300
|
+
const parsed = Number.parseInt(raw, 10)
|
|
301
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The write-classified leading verbs (DDL included). Everything else —
|
|
305
|
+
* SELECT, the PRAGMA probes, EXPLAIN — is a read and stays unbounded. */
|
|
306
|
+
const WRITE_STATEMENT = /^\s*(?:insert|update|delete|replace|create|alter|drop|vacuum|reindex)\b/i
|
|
307
|
+
|
|
308
|
+
/** The statement's idempotency posture for StoreUnavailable.retrySafe:
|
|
309
|
+
* the shapes whose replay converges after a write that may have
|
|
310
|
+
* landed — keyed UPDATE/DELETE/REPLACE, INSERT OR IGNORE/REPLACE, the
|
|
311
|
+
* ON CONFLICT upserts, the IF NOT EXISTS heals. A plain INSERT is NOT
|
|
312
|
+
* claimed safe (a retry could double-land). */
|
|
313
|
+
function writeRetrySafe(sql: string): boolean {
|
|
314
|
+
if (/^\s*(?:update|delete|replace)\b/i.test(sql)) return true
|
|
315
|
+
if (/^\s*insert\s+or\s+(?:ignore|replace)\b/i.test(sql)) return true
|
|
316
|
+
if (/^\s*insert\b/i.test(sql) && /\bon\s+conflict\b/i.test(sql)) return true
|
|
317
|
+
if (/^\s*create\b/i.test(sql) && /\bif\s+not\s+exists\b/i.test(sql)) return true
|
|
318
|
+
return false
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The error/log label for a write: the verb phrase + the target object
|
|
322
|
+
* ('UPDATE sessions', 'CREATE TABLE IF NOT EXISTS org_memberships').
|
|
323
|
+
* Never the bound values — the label is all an error may carry. */
|
|
324
|
+
function sqlWriteLabel(sql: string): string {
|
|
325
|
+
const flat = sql.trim().replace(/\s+/g, ' ')
|
|
326
|
+
const m = /^((?:insert(?:\s+or\s+(?:ignore|replace))?\s+into|update|delete\s+from|replace\s+into|create\s+(?:unique\s+)?(?:table|index)(?:\s+if\s+not\s+exists)?|alter\s+table|drop\s+(?:table|index)(?:\s+if\s+exists)?))\s+("?\w+"?)/i.exec(flat)
|
|
327
|
+
if (m) return `${m[1].toUpperCase()} ${m[2]}`
|
|
328
|
+
return flat.slice(0, 60)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Race a write against its confirmation budget. The in-flight write is
|
|
332
|
+
* never cancelled (D1 has no abort) — the race's handlers stay attached
|
|
333
|
+
* past the timeout, so a late settling (success OR failure) never
|
|
334
|
+
* surfaces as an unhandled rejection. */
|
|
335
|
+
function raceWriteBudget<T>(operation: string, budgetMs: number, retrySafe: boolean, pending: Promise<T>): Promise<T> {
|
|
336
|
+
return new Promise<T>((resolve, reject) => {
|
|
337
|
+
const timer = setTimeout(() => reject(new StoreUnavailable(operation, budgetMs, retrySafe)), budgetMs)
|
|
338
|
+
pending.then(
|
|
339
|
+
value => { clearTimeout(timer); resolve(value) },
|
|
340
|
+
(error: unknown) => { clearTimeout(timer); reject(error) },
|
|
341
|
+
)
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The wrapper → the underlying statement (+ its idempotency posture).
|
|
346
|
+
* batch() hands the REAL statements back to the binding: the runtime
|
|
347
|
+
* reads the SQL off the statement object itself, so a wrapper is not a
|
|
348
|
+
* statement to it. WeakMap-keyed — the entries die with the wrappers. */
|
|
349
|
+
const unwrappedStatements = new WeakMap<D1PreparedStatement, { stmt: D1PreparedStatement; retrySafe: boolean }>()
|
|
350
|
+
|
|
351
|
+
/** The write-bounded statement facade: read-classified SQL returns the
|
|
352
|
+
* raw statement untouched; write-classified SQL gets every terminal
|
|
353
|
+
* (run/first/all/raw — INSERT … RETURNING reads back through a write)
|
|
354
|
+
* raced against the budget. bind() stays chainable on the wrapper. */
|
|
355
|
+
function boundedStatement(stmt: D1PreparedStatement, sql: string, budgetMs: number): D1PreparedStatement {
|
|
356
|
+
if (!WRITE_STATEMENT.test(sql)) return stmt
|
|
357
|
+
const operation = sqlWriteLabel(sql)
|
|
358
|
+
const retrySafe = writeRetrySafe(sql)
|
|
359
|
+
const wrap = (s: D1PreparedStatement): D1PreparedStatement => {
|
|
360
|
+
const wrapped = {
|
|
361
|
+
bind: (...values: unknown[]): D1PreparedStatement => wrap(s.bind(...values)),
|
|
362
|
+
first: (<T = unknown>(colName?: string) =>
|
|
363
|
+
raceWriteBudget(operation, budgetMs, retrySafe, (colName === undefined ? s.first<T>() : s.first<T>(colName)) as Promise<T | null>)
|
|
364
|
+
) as D1PreparedStatement['first'],
|
|
365
|
+
run: (<T = Record<string, unknown>>() =>
|
|
366
|
+
raceWriteBudget(operation, budgetMs, retrySafe, s.run<T>())
|
|
367
|
+
) as D1PreparedStatement['run'],
|
|
368
|
+
all: (<T = Record<string, unknown>>() =>
|
|
369
|
+
raceWriteBudget(operation, budgetMs, retrySafe, s.all<T>())
|
|
370
|
+
) as D1PreparedStatement['all'],
|
|
371
|
+
raw: (<T = unknown[]>(options?: { columnNames?: boolean }) =>
|
|
372
|
+
options?.columnNames
|
|
373
|
+
? raceWriteBudget(operation, budgetMs, retrySafe, s.raw<T>({ columnNames: true }))
|
|
374
|
+
: raceWriteBudget(operation, budgetMs, retrySafe, s.raw<T>(options as { columnNames?: false } | undefined))
|
|
375
|
+
) as D1PreparedStatement['raw'],
|
|
376
|
+
}
|
|
377
|
+
unwrappedStatements.set(wrapped as D1PreparedStatement, { stmt: s, retrySafe })
|
|
378
|
+
return wrapped as D1PreparedStatement
|
|
379
|
+
}
|
|
380
|
+
return wrap(stmt)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** The binding facade the store holds: prepare() bounds write-classified
|
|
384
|
+
* statements, batch()/exec() race their own budget (a D1 batch is ONE
|
|
385
|
+
* round-trip — one budget, retry-safe exactly when every member is),
|
|
386
|
+
* dump()/the reads pass through, withSession()'s session gets the same
|
|
387
|
+
* discipline. One budget per round-trip, never per row. */
|
|
388
|
+
function boundedD1Writes(binding: D1Database, budgetMs: number): D1Database {
|
|
389
|
+
const boundedBatch = <T>(run: (real: D1PreparedStatement[]) => Promise<T>, statements: D1PreparedStatement[],): Promise<T> => {
|
|
390
|
+
const real = statements.map(s => unwrappedStatements.get(s)?.stmt ?? s)
|
|
391
|
+
const retrySafe = statements.length > 0 && statements.every(s => unwrappedStatements.get(s)?.retrySafe === true)
|
|
392
|
+
return raceWriteBudget(`batch (${statements.length} statements)`, budgetMs, retrySafe, run(real))
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
prepare: (sql: string) => boundedStatement(binding.prepare(sql), sql, budgetMs),
|
|
396
|
+
batch: <T = unknown>(statements: D1PreparedStatement[]) => boundedBatch(r => binding.batch<T>(r), statements),
|
|
397
|
+
exec: (sql: string) => raceWriteBudget('exec (DDL script)', budgetMs, false, binding.exec(sql)),
|
|
398
|
+
dump: () => binding.dump(),
|
|
399
|
+
withSession: (constraintOrBookmark?: string) => {
|
|
400
|
+
const session = binding.withSession(constraintOrBookmark)
|
|
401
|
+
return {
|
|
402
|
+
prepare: (sql: string) => boundedStatement(session.prepare(sql), sql, budgetMs),
|
|
403
|
+
batch: <T = unknown>(statements: D1PreparedStatement[]) => boundedBatch(r => session.batch<T>(r), statements),
|
|
404
|
+
getBookmark: () => session.getBookmark(),
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
191
410
|
export class D1ServerStore implements ServerStore {
|
|
192
|
-
|
|
411
|
+
/** The RAW binding — the ensure memos (and d1StoreFor's map) key on
|
|
412
|
+
* it: the facade below is per-instance and would never hit. */
|
|
413
|
+
private readonly binding: D1Database
|
|
414
|
+
/** The bounded-write facade over the binding (the discipline note
|
|
415
|
+
* above) — every statement/batch the store issues flows through it. */
|
|
416
|
+
private readonly db: D1Database
|
|
417
|
+
|
|
418
|
+
constructor(binding: D1Database, opts?: D1WriteBudgetOptions) {
|
|
419
|
+
this.binding = binding
|
|
420
|
+
this.db = boundedD1Writes(binding, opts?.writeBudgetMs ?? DEFAULT_STORE_WRITE_BUDGET_MS)
|
|
421
|
+
}
|
|
193
422
|
|
|
194
423
|
private stmt(sql: string, ...params: unknown[]): D1PreparedStatement {
|
|
195
424
|
return this.db.prepare(sql).bind(...params)
|
|
@@ -198,43 +427,34 @@ export class D1ServerStore implements ServerStore {
|
|
|
198
427
|
// TODO.federation/12: the roles/active columns arrive with migration
|
|
199
428
|
// 0002 — a dev D1 migrated from the pre-RBAC 0001 lacks them, so the
|
|
200
429
|
// user methods ensure them defensively (PRAGMA probe + ALTER),
|
|
201
|
-
// memoized per
|
|
202
|
-
private usersColumnsEnsured: Promise<void> | null = null
|
|
203
|
-
|
|
430
|
+
// memoized per (binding, chain) at module scope (the header note).
|
|
204
431
|
private ensureUserColumns(): Promise<void> {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
})()
|
|
215
|
-
}
|
|
216
|
-
return this.usersColumnsEnsured
|
|
432
|
+
return ensured(this.binding, 'usersColumns', async () => {
|
|
433
|
+
const cols = await this.db.prepare('PRAGMA table_info(users)').all<{ name: string }>()
|
|
434
|
+
const names = new Set(cols.results.map(c => c.name))
|
|
435
|
+
if (!names.has('roles')) await this.db.prepare('ALTER TABLE users ADD COLUMN roles TEXT').run()
|
|
436
|
+
if (!names.has('active')) await this.db.prepare('ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1').run()
|
|
437
|
+
// TODO.identity/06 (the account console): the address's
|
|
438
|
+
// verification state.
|
|
439
|
+
if (!names.has('email_verified_at')) await this.db.prepare('ALTER TABLE users ADD COLUMN email_verified_at TEXT').run()
|
|
440
|
+
})
|
|
217
441
|
}
|
|
218
442
|
|
|
219
443
|
// TODO.identity/06 (the account console's sessions section): the
|
|
220
444
|
// sign-in context columns arrive with migration 0009 — a dev D1
|
|
221
445
|
// migrated from before it lacks them, so the session methods ensure
|
|
222
|
-
// them defensively (the ensureUserColumns posture, memoized per
|
|
223
|
-
|
|
224
|
-
|
|
446
|
+
// them defensively (the ensureUserColumns posture, memoized per
|
|
447
|
+
// (binding, chain) at module scope).
|
|
225
448
|
private ensureSessionColumns(): Promise<void> {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
})()
|
|
236
|
-
}
|
|
237
|
-
return this.sessionColumnsEnsured
|
|
449
|
+
return ensured(this.binding, 'sessionColumns', async () => {
|
|
450
|
+
const cols = await this.db.prepare('PRAGMA table_info(sessions)').all<{ name: string }>()
|
|
451
|
+
const names = new Set(cols.results.map(c => c.name))
|
|
452
|
+
if (!names.has('user_agent')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN user_agent TEXT').run()
|
|
453
|
+
if (!names.has('ip')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN ip TEXT').run()
|
|
454
|
+
if (!names.has('last_seen_at')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN last_seen_at TEXT').run()
|
|
455
|
+
// TODO.identity-sso/02+03: the sign-in provenance.
|
|
456
|
+
if (!names.has('amr')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN amr TEXT').run()
|
|
457
|
+
})
|
|
238
458
|
}
|
|
239
459
|
|
|
240
460
|
// TODO.identity/11 (the multi-org membership model): the
|
|
@@ -242,304 +462,267 @@ export class D1ServerStore implements ServerStore {
|
|
|
242
462
|
// token-flow context columns arrive with migration 0011 — a dev D1
|
|
243
463
|
// migrated from before it lacks them, so the membership/session/token
|
|
244
464
|
// methods ensure them defensively (the ensureUserColumns posture,
|
|
245
|
-
// memoized per
|
|
246
|
-
// IDEMPOTENT and rides the ensure: every
|
|
247
|
-
// membership mirrors the legacy columns.
|
|
248
|
-
private membershipSupportEnsured: Promise<void> | null = null
|
|
249
|
-
|
|
465
|
+
// memoized per (binding, chain) at module scope). The backfill (the
|
|
466
|
+
// migration's twin) is IDEMPOTENT and rides the ensure: every
|
|
467
|
+
// org-bound account's primary membership mirrors the legacy columns.
|
|
250
468
|
private ensureMembershipSupport(): Promise<void> {
|
|
251
|
-
|
|
252
|
-
this.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
})()
|
|
299
|
-
}
|
|
300
|
-
return this.membershipSupportEnsured
|
|
469
|
+
return ensured(this.binding, 'membershipSupport', async () => {
|
|
470
|
+
await this.db.prepare(
|
|
471
|
+
`CREATE TABLE IF NOT EXISTS org_memberships (
|
|
472
|
+
id TEXT PRIMARY KEY,
|
|
473
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
474
|
+
org_id TEXT NOT NULL,
|
|
475
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
476
|
+
state TEXT NOT NULL DEFAULT 'active',
|
|
477
|
+
is_primary INTEGER NOT NULL DEFAULT 0,
|
|
478
|
+
invited_by TEXT,
|
|
479
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
480
|
+
activated_at TEXT,
|
|
481
|
+
disabled_at TEXT,
|
|
482
|
+
disabled_by TEXT,
|
|
483
|
+
UNIQUE (user_id, org_id)
|
|
484
|
+
)`,
|
|
485
|
+
).run()
|
|
486
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_memberships_org ON org_memberships (org_id, state)').run()
|
|
487
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_memberships_user ON org_memberships (user_id, state)').run()
|
|
488
|
+
// TODO.identity-features/09 (the org-member data cone): the cone
|
|
489
|
+
// column arrives with migration 0017 — a dev D1 predating it
|
|
490
|
+
// grows the column here. NULL = org-wide: existing memberships
|
|
491
|
+
// keep their posture silently.
|
|
492
|
+
const membershipCols = await this.db.prepare('PRAGMA table_info(org_memberships)').all<{ name: string }>()
|
|
493
|
+
if (!membershipCols.results.some(c => c.name === 'cone')) {
|
|
494
|
+
await this.db.prepare('ALTER TABLE org_memberships ADD COLUMN cone TEXT').run()
|
|
495
|
+
}
|
|
496
|
+
const sessionCols = await this.db.prepare('PRAGMA table_info(sessions)').all<{ name: string }>()
|
|
497
|
+
if (!sessionCols.results.some(c => c.name === 'active_org')) {
|
|
498
|
+
await this.db.prepare('ALTER TABLE sessions ADD COLUMN active_org TEXT').run()
|
|
499
|
+
}
|
|
500
|
+
const codeCols = await this.db.prepare('PRAGMA table_info(oidc_codes)').all<{ name: string }>()
|
|
501
|
+
if (!codeCols.results.some(c => c.name === 'context_org')) {
|
|
502
|
+
await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN context_org TEXT').run()
|
|
503
|
+
}
|
|
504
|
+
const accessCols = await this.db.prepare('PRAGMA table_info(oidc_access_tokens)').all<{ name: string }>()
|
|
505
|
+
if (!accessCols.results.some(c => c.name === 'context_org')) {
|
|
506
|
+
await this.db.prepare('ALTER TABLE oidc_access_tokens ADD COLUMN context_org TEXT').run()
|
|
507
|
+
}
|
|
508
|
+
await this.db.prepare(
|
|
509
|
+
`INSERT OR IGNORE INTO org_memberships (id, user_id, org_id, roles, state, is_primary, activated_at)
|
|
510
|
+
SELECT 'mbr-' || id, id, org_id,
|
|
511
|
+
CASE WHEN roles IS NOT NULL AND roles != '' THEN roles ELSE json_array(role) END,
|
|
512
|
+
'active', 1, COALESCE(last_login, created_at)
|
|
513
|
+
FROM users WHERE org_id IS NOT NULL`,
|
|
514
|
+
).run()
|
|
515
|
+
})
|
|
301
516
|
}
|
|
302
517
|
|
|
303
518
|
// TODO.identity-features/05 (the organization registry): the
|
|
304
519
|
// org_registry table arrives with migration 0013 — a dev D1 migrated
|
|
305
520
|
// from before it lacks the table, so the registry methods ensure it
|
|
306
521
|
// defensively (the ensureMembershipSupport posture, memoized per
|
|
307
|
-
//
|
|
308
|
-
private orgRegistrySupportEnsured: Promise<void> | null = null
|
|
309
|
-
|
|
522
|
+
// (binding, chain) at module scope).
|
|
310
523
|
private ensureOrgRegistrySupport(): Promise<void> {
|
|
311
|
-
|
|
312
|
-
this.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
})()
|
|
343
|
-
}
|
|
344
|
-
return this.orgRegistrySupportEnsured
|
|
524
|
+
return ensured(this.binding, 'orgRegistrySupport', async () => {
|
|
525
|
+
await this.db.prepare(
|
|
526
|
+
`CREATE TABLE IF NOT EXISTS org_registry (
|
|
527
|
+
id TEXT PRIMARY KEY,
|
|
528
|
+
name TEXT NOT NULL,
|
|
529
|
+
short_name TEXT,
|
|
530
|
+
kind TEXT,
|
|
531
|
+
country TEXT,
|
|
532
|
+
contacts TEXT NOT NULL DEFAULT '[]',
|
|
533
|
+
participant_ref TEXT,
|
|
534
|
+
state TEXT NOT NULL DEFAULT 'active',
|
|
535
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
536
|
+
created_by TEXT,
|
|
537
|
+
updated_at TEXT,
|
|
538
|
+
updated_by TEXT,
|
|
539
|
+
disabled_at TEXT,
|
|
540
|
+
disabled_by TEXT
|
|
541
|
+
)`,
|
|
542
|
+
).run()
|
|
543
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_registry_state ON org_registry (state)').run()
|
|
544
|
+
// TODO.identity-features/10 (the OIML Member category): the
|
|
545
|
+
// designation links + the CS status facet arrive with migration
|
|
546
|
+
// 0019 — a dev D1 predating it grows the columns here (the
|
|
547
|
+
// PRAGMA probe + ALTER posture of ensureUserColumns). NULL = not
|
|
548
|
+
// recorded: existing rows keep their posture silently.
|
|
549
|
+
const cols = await this.db.prepare('PRAGMA table_info(org_registry)').all<{ name: string }>()
|
|
550
|
+
const names = new Set(cols.results.map(c => c.name))
|
|
551
|
+
if (!names.has('designated_by')) await this.db.prepare('ALTER TABLE org_registry ADD COLUMN designated_by TEXT').run()
|
|
552
|
+
if (!names.has('proposed_by')) await this.db.prepare('ALTER TABLE org_registry ADD COLUMN proposed_by TEXT').run()
|
|
553
|
+
if (!names.has('cs_status')) await this.db.prepare('ALTER TABLE org_registry ADD COLUMN cs_status TEXT').run()
|
|
554
|
+
})
|
|
345
555
|
}
|
|
346
556
|
|
|
347
557
|
// TODO.register/02 (the register's holder-org attribution): the
|
|
348
558
|
// certificate_holder_orgs / certificate_holder_claims tables arrive with
|
|
349
559
|
// migration 0015 — a dev D1 migrated from before it lacks them, so the
|
|
350
560
|
// attribution/claim methods ensure them defensively (the
|
|
351
|
-
// ensureOrgRegistrySupport posture, memoized per
|
|
352
|
-
|
|
353
|
-
|
|
561
|
+
// ensureOrgRegistrySupport posture, memoized per (binding, chain) at
|
|
562
|
+
// module scope).
|
|
354
563
|
private ensureHolderAttributionSupport(): Promise<void> {
|
|
355
|
-
|
|
356
|
-
this.
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
})()
|
|
388
|
-
}
|
|
389
|
-
return this.holderAttributionSupportEnsured
|
|
564
|
+
return ensured(this.binding, 'holderAttributionSupport', async () => {
|
|
565
|
+
await this.db.prepare(
|
|
566
|
+
`CREATE TABLE IF NOT EXISTS certificate_holder_orgs (
|
|
567
|
+
certificate_id TEXT PRIMARY KEY,
|
|
568
|
+
org_id TEXT NOT NULL,
|
|
569
|
+
org_name TEXT NOT NULL,
|
|
570
|
+
source TEXT NOT NULL,
|
|
571
|
+
attributed_at TEXT NOT NULL,
|
|
572
|
+
attributed_by TEXT,
|
|
573
|
+
claim_id TEXT
|
|
574
|
+
)`,
|
|
575
|
+
).run()
|
|
576
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_orgs_org ON certificate_holder_orgs (org_id)').run()
|
|
577
|
+
await this.db.prepare(
|
|
578
|
+
`CREATE TABLE IF NOT EXISTS certificate_holder_claims (
|
|
579
|
+
id TEXT PRIMARY KEY,
|
|
580
|
+
certificate_id TEXT NOT NULL,
|
|
581
|
+
claimant_org_id TEXT NOT NULL,
|
|
582
|
+
claimant_org_name TEXT NOT NULL,
|
|
583
|
+
matched_holder_name TEXT NOT NULL,
|
|
584
|
+
claimed_by TEXT NOT NULL,
|
|
585
|
+
state TEXT NOT NULL DEFAULT 'pending',
|
|
586
|
+
decided_by TEXT,
|
|
587
|
+
decided_at TEXT,
|
|
588
|
+
refusal_reason TEXT,
|
|
589
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
590
|
+
)`,
|
|
591
|
+
).run()
|
|
592
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_cert ON certificate_holder_claims (certificate_id)').run()
|
|
593
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_state ON certificate_holder_claims (state)').run()
|
|
594
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_org ON certificate_holder_claims (claimant_org_id)').run()
|
|
595
|
+
})
|
|
390
596
|
}
|
|
391
597
|
|
|
392
598
|
// TODO.register/03 (the instrument register): the
|
|
393
599
|
// instrument_registrations table arrives with migration 0016 — a dev
|
|
394
600
|
// D1 migrated from before it lacks the table, so the register methods
|
|
395
601
|
// ensure it defensively (the ensureOrgRegistrySupport posture,
|
|
396
|
-
// memoized per
|
|
397
|
-
private instrumentRegistrationSupportEnsured: Promise<void> | null = null
|
|
398
|
-
|
|
602
|
+
// memoized per (binding, chain) at module scope).
|
|
399
603
|
private ensureInstrumentRegistrationSupport(): Promise<void> {
|
|
400
|
-
|
|
401
|
-
this.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
})()
|
|
425
|
-
}
|
|
426
|
-
return this.instrumentRegistrationSupportEnsured
|
|
604
|
+
return ensured(this.binding, 'instrumentRegistrationSupport', async () => {
|
|
605
|
+
await this.db.prepare(
|
|
606
|
+
`CREATE TABLE IF NOT EXISTS instrument_registrations (
|
|
607
|
+
id TEXT PRIMARY KEY,
|
|
608
|
+
certificate_id TEXT NOT NULL,
|
|
609
|
+
holder_org_id TEXT NOT NULL,
|
|
610
|
+
standard_id TEXT NOT NULL,
|
|
611
|
+
serial_number TEXT NOT NULL,
|
|
612
|
+
manufacture_date TEXT,
|
|
613
|
+
designations TEXT NOT NULL DEFAULT '{}',
|
|
614
|
+
scope_status TEXT NOT NULL,
|
|
615
|
+
scope_detail TEXT,
|
|
616
|
+
lifecycle TEXT NOT NULL DEFAULT 'registered',
|
|
617
|
+
registered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
618
|
+
registered_by TEXT,
|
|
619
|
+
updated_at TEXT,
|
|
620
|
+
updated_by TEXT,
|
|
621
|
+
UNIQUE (certificate_id, serial_number)
|
|
622
|
+
)`,
|
|
623
|
+
).run()
|
|
624
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_certificate ON instrument_registrations (certificate_id)').run()
|
|
625
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_holder ON instrument_registrations (holder_org_id)').run()
|
|
626
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_lifecycle ON instrument_registrations (lifecycle)').run()
|
|
627
|
+
})
|
|
427
628
|
}
|
|
428
629
|
|
|
429
630
|
// TODO.identity-sso/02+03: the amr provenance columns on the OIDC
|
|
430
631
|
// flow rows arrive with migration 0012 — the OIDC methods ensure them
|
|
431
|
-
// defensively (the same memoized posture as the session/user columns
|
|
432
|
-
// so a dev D1 migrated from
|
|
433
|
-
|
|
434
|
-
|
|
632
|
+
// defensively (the same memoized posture as the session/user columns,
|
|
633
|
+
// per (binding, chain) at module scope), so a dev D1 migrated from
|
|
634
|
+
// before the wave never 500s the core flow.
|
|
435
635
|
private ensureOidcColumns(): Promise<void> {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
})()
|
|
447
|
-
}
|
|
448
|
-
return this.oidcColumnsEnsured
|
|
636
|
+
return ensured(this.binding, 'oidcColumns', async () => {
|
|
637
|
+
const codeCols = await this.db.prepare('PRAGMA table_info(oidc_codes)').all<{ name: string }>()
|
|
638
|
+
if (!codeCols.results.some(c => c.name === 'amr')) {
|
|
639
|
+
await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN amr TEXT').run()
|
|
640
|
+
}
|
|
641
|
+
const tokenCols = await this.db.prepare('PRAGMA table_info(oidc_access_tokens)').all<{ name: string }>()
|
|
642
|
+
if (!tokenCols.results.some(c => c.name === 'amr')) {
|
|
643
|
+
await this.db.prepare('ALTER TABLE oidc_access_tokens ADD COLUMN amr TEXT').run()
|
|
644
|
+
}
|
|
645
|
+
})
|
|
449
646
|
}
|
|
450
647
|
|
|
451
648
|
// TODO.identity-features/08 (the personal access tokens): the
|
|
452
649
|
// personal_access_tokens table arrives with migration 0020 — a dev D1
|
|
453
650
|
// migrated from before it lacks the table, so the PAT methods ensure
|
|
454
651
|
// it defensively (the ensureOrgRegistrySupport posture, memoized per
|
|
455
|
-
//
|
|
456
|
-
private personalAccessTokenSupportEnsured: Promise<void> | null = null
|
|
457
|
-
|
|
652
|
+
// (binding, chain) at module scope).
|
|
458
653
|
private ensurePersonalAccessTokenSupport(): Promise<void> {
|
|
459
|
-
|
|
460
|
-
this.
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
})()
|
|
482
|
-
}
|
|
483
|
-
return this.personalAccessTokenSupportEnsured
|
|
654
|
+
return ensured(this.binding, 'personalAccessTokenSupport', async () => {
|
|
655
|
+
await this.db.prepare(
|
|
656
|
+
`CREATE TABLE IF NOT EXISTS personal_access_tokens (
|
|
657
|
+
id TEXT PRIMARY KEY,
|
|
658
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
659
|
+
name TEXT NOT NULL,
|
|
660
|
+
token_hash TEXT NOT NULL,
|
|
661
|
+
token_prefix TEXT NOT NULL,
|
|
662
|
+
scopes TEXT NOT NULL DEFAULT '[]',
|
|
663
|
+
org_context TEXT,
|
|
664
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
665
|
+
expires_at TEXT NOT NULL,
|
|
666
|
+
last_used_at TEXT,
|
|
667
|
+
last_exchange_audit_at TEXT,
|
|
668
|
+
expiry_notified_at TEXT,
|
|
669
|
+
revoked_at TEXT,
|
|
670
|
+
revoked_by TEXT,
|
|
671
|
+
UNIQUE (token_hash)
|
|
672
|
+
)`,
|
|
673
|
+
).run()
|
|
674
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_personal_access_tokens_user ON personal_access_tokens (user_id)').run()
|
|
675
|
+
})
|
|
484
676
|
}
|
|
485
677
|
|
|
486
678
|
// TODO.identity-features/12 (the remembered consent grants): the
|
|
487
679
|
// oidc_consent_grants table arrives with migration 0021 — a dev D1
|
|
488
680
|
// migrated from before it lacks the table, so the grant methods ensure
|
|
489
681
|
// it defensively (the ensurePersonalAccessTokenSupport posture,
|
|
490
|
-
// memoized per
|
|
491
|
-
private consentGrantSupportEnsured: Promise<void> | null = null
|
|
492
|
-
|
|
682
|
+
// memoized per (binding, chain) at module scope).
|
|
493
683
|
private ensureConsentGrantSupport(): Promise<void> {
|
|
494
|
-
|
|
495
|
-
this.
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
})()
|
|
511
|
-
}
|
|
512
|
-
return this.consentGrantSupportEnsured
|
|
684
|
+
return ensured(this.binding, 'consentGrantSupport', async () => {
|
|
685
|
+
await this.db.prepare(
|
|
686
|
+
`CREATE TABLE IF NOT EXISTS oidc_consent_grants (
|
|
687
|
+
id TEXT PRIMARY KEY,
|
|
688
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
689
|
+
client_id TEXT NOT NULL,
|
|
690
|
+
scope TEXT NOT NULL,
|
|
691
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
692
|
+
revoked_at TEXT
|
|
693
|
+
)`,
|
|
694
|
+
).run()
|
|
695
|
+
await this.db.prepare(
|
|
696
|
+
'CREATE UNIQUE INDEX IF NOT EXISTS idx_oidc_consent_grants_live ON oidc_consent_grants (user_id, client_id, scope) WHERE revoked_at IS NULL',
|
|
697
|
+
).run()
|
|
698
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_oidc_consent_grants_user ON oidc_consent_grants (user_id)').run()
|
|
699
|
+
})
|
|
513
700
|
}
|
|
514
701
|
|
|
515
702
|
// TODO.identity-features/01 (multiple emails per account): the
|
|
516
703
|
// account_emails table + the email_change_tokens.kind column arrive
|
|
517
704
|
// with migration 0022 — a dev D1 migrated from before it lacks both,
|
|
518
705
|
// so the address methods ensure them defensively (the
|
|
519
|
-
// ensureConsentGrantSupport posture, memoized per
|
|
520
|
-
|
|
521
|
-
|
|
706
|
+
// ensureConsentGrantSupport posture, memoized per (binding, chain) at
|
|
707
|
+
// module scope).
|
|
522
708
|
private ensureAccountEmailSupport(): Promise<void> {
|
|
523
|
-
|
|
524
|
-
this.
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
})()
|
|
541
|
-
}
|
|
542
|
-
return this.accountEmailSupportEnsured
|
|
709
|
+
return ensured(this.binding, 'accountEmailSupport', async () => {
|
|
710
|
+
await this.db.prepare(
|
|
711
|
+
`CREATE TABLE IF NOT EXISTS account_emails (
|
|
712
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
713
|
+
email TEXT NOT NULL,
|
|
714
|
+
verified_at TEXT,
|
|
715
|
+
added_by TEXT,
|
|
716
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
717
|
+
PRIMARY KEY (user_id, email)
|
|
718
|
+
)`,
|
|
719
|
+
).run()
|
|
720
|
+
await this.db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_account_emails_email ON account_emails (email)').run()
|
|
721
|
+
const tokenCols = await this.db.prepare('PRAGMA table_info(email_change_tokens)').all<{ name: string }>()
|
|
722
|
+
if (!tokenCols.results.some(c => c.name === 'kind')) {
|
|
723
|
+
await this.db.prepare("ALTER TABLE email_change_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'change'").run()
|
|
724
|
+
}
|
|
725
|
+
})
|
|
543
726
|
}
|
|
544
727
|
|
|
545
728
|
// ── users / sessions ─────────────────────────────────────────────
|
|
@@ -648,7 +831,28 @@ export class D1ServerStore implements ServerStore {
|
|
|
648
831
|
return row?.id_token_hint ?? null
|
|
649
832
|
}
|
|
650
833
|
|
|
834
|
+
/** TODO.identity/06's last-active stamp, throttled to one ISSUED write
|
|
835
|
+
* per minute per session per isolate — the DB-side 60 s WHERE clause
|
|
836
|
+
* stays the source of truth across isolates (the module-scope note);
|
|
837
|
+
* the in-isolate cache only skips issuing a write the row would
|
|
838
|
+
* refuse. A failed write is never cached (the cache set follows the
|
|
839
|
+
* await), so the next request retries. */
|
|
840
|
+
private async stampSessionLastSeen(token: string): Promise<void> {
|
|
841
|
+
const now = Date.now()
|
|
842
|
+
if (now - (lastSeenWrites.get(token) ?? 0) < LAST_SEEN_THROTTLE_MS) return
|
|
843
|
+
await this.stmt(
|
|
844
|
+
"UPDATE sessions SET last_seen_at = datetime('now') WHERE token = ? AND (last_seen_at IS NULL OR last_seen_at < datetime('now', '-60 seconds'))",
|
|
845
|
+
token,
|
|
846
|
+
).run()
|
|
847
|
+
if (lastSeenWrites.size >= LAST_SEEN_CACHE_CAP) lastSeenWrites.clear()
|
|
848
|
+
lastSeenWrites.set(token, now)
|
|
849
|
+
}
|
|
850
|
+
|
|
651
851
|
async getSessionUser(token: string): Promise<AuthUserPayload | null> {
|
|
852
|
+
// The ensure chains are memoized per (binding, chain) at module scope
|
|
853
|
+
// (the header note) — past the isolate's first request these three
|
|
854
|
+
// awaits are memo hits, and the per-request work that remains is the
|
|
855
|
+
// session read itself.
|
|
652
856
|
await this.ensureUserColumns()
|
|
653
857
|
await this.ensureSessionColumns()
|
|
654
858
|
await this.ensureMembershipSupport()
|
|
@@ -662,12 +866,6 @@ export class D1ServerStore implements ServerStore {
|
|
|
662
866
|
token,
|
|
663
867
|
).first<{ user_id: string; active_org: string | null; amr: string | null; email: string; name: string; role: string; roles: string | null; org_id: string | null; avatar_url: string | null; provider: string; email_verified_at: string | null }>()
|
|
664
868
|
if (!session) return null
|
|
665
|
-
// TODO.identity/06: the last-active stamp, throttled to one write
|
|
666
|
-
// per minute per session (the sessions section shows it).
|
|
667
|
-
await this.stmt(
|
|
668
|
-
"UPDATE sessions SET last_seen_at = datetime('now') WHERE token = ? AND (last_seen_at IS NULL OR last_seen_at < datetime('now', '-60 seconds'))",
|
|
669
|
-
token,
|
|
670
|
-
).run()
|
|
671
869
|
const amr = parseRoles(session.amr)
|
|
672
870
|
const payload: AuthUserPayload = {
|
|
673
871
|
id: session.user_id,
|
|
@@ -682,10 +880,16 @@ export class D1ServerStore implements ServerStore {
|
|
|
682
880
|
...(amr?.length ? { amr } : {}),
|
|
683
881
|
}
|
|
684
882
|
// TODO.identity/11: the active-org context (the membership model) —
|
|
685
|
-
// the payload's org/roles follow the session's stamped context.
|
|
883
|
+
// the payload's org/roles follow the session's stamped context. The
|
|
884
|
+
// three post-JOIN statements (the throttled last-active stamp, the
|
|
885
|
+
// two membership reads) are independent, so they round-trip together
|
|
886
|
+
// (the audit's R2: sequential statements are the latency).
|
|
686
887
|
const activeOrg = session.active_org ?? null
|
|
687
|
-
const active =
|
|
688
|
-
|
|
888
|
+
const [, active, primary] = await Promise.all([
|
|
889
|
+
this.stampSessionLastSeen(token),
|
|
890
|
+
activeOrg ? this.getOrgMembership(payload.id, activeOrg) : null,
|
|
891
|
+
payload.orgId ? this.getOrgMembership(payload.id, payload.orgId) : null,
|
|
892
|
+
])
|
|
689
893
|
const resolved = resolveOrgContext(payload, { activeOrg, active, primary })
|
|
690
894
|
if (activeOrg && !(active && active.state === 'active')) {
|
|
691
895
|
// The stale stamp never lingers (the membership ended mid-session).
|
|
@@ -2526,6 +2730,14 @@ export class D1ServerStore implements ServerStore {
|
|
|
2526
2730
|
return res.results.map(D1ServerStore.toOrgMembership)
|
|
2527
2731
|
}
|
|
2528
2732
|
|
|
2733
|
+
async listAllOrgMemberships(): Promise<OrgMembership[]> {
|
|
2734
|
+
await this.ensureMembershipSupport()
|
|
2735
|
+
const res = await this.stmt(
|
|
2736
|
+
'SELECT * FROM org_memberships ORDER BY org_id, created_at',
|
|
2737
|
+
).all<Record<string, unknown>>()
|
|
2738
|
+
return res.results.map(D1ServerStore.toOrgMembership)
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2529
2741
|
async getOrgMembership(userId: string, orgId: string): Promise<OrgMembership | null> {
|
|
2530
2742
|
await this.ensureMembershipSupport()
|
|
2531
2743
|
const row = await this.stmt('SELECT * FROM org_memberships WHERE user_id = ? AND org_id = ?', userId, orgId)
|
|
@@ -3320,31 +3532,27 @@ export class D1ServerStore implements ServerStore {
|
|
|
3320
3532
|
// The SAME statements as sqlite/notify.ts's sync half (D1 is SQLite).
|
|
3321
3533
|
// The defensive ensure mirrors the instrument_registrations posture:
|
|
3322
3534
|
// a dev D1 migrated from before migration 0018 lacks the table.
|
|
3323
|
-
|
|
3324
|
-
private notifyDeliverySupportEnsured: Promise<void> | null = null
|
|
3535
|
+
// (Memoized per (binding, chain) at module scope — the header note.)
|
|
3325
3536
|
|
|
3326
3537
|
private ensureNotifyDeliverySupport(): Promise<void> {
|
|
3327
|
-
|
|
3328
|
-
this.
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
})()
|
|
3346
|
-
}
|
|
3347
|
-
return this.notifyDeliverySupportEnsured
|
|
3538
|
+
return ensured(this.binding, 'notifyDeliverySupport', async () => {
|
|
3539
|
+
await this.db.prepare(
|
|
3540
|
+
`CREATE TABLE IF NOT EXISTS notify_deliveries (
|
|
3541
|
+
id TEXT PRIMARY KEY,
|
|
3542
|
+
event_id TEXT NOT NULL,
|
|
3543
|
+
user_id TEXT NOT NULL,
|
|
3544
|
+
reason TEXT NOT NULL,
|
|
3545
|
+
email TEXT NOT NULL,
|
|
3546
|
+
email_status TEXT,
|
|
3547
|
+
email_at TEXT,
|
|
3548
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
3549
|
+
UNIQUE (event_id, user_id)
|
|
3550
|
+
)`,
|
|
3551
|
+
).run()
|
|
3552
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_event ON notify_deliveries (event_id)').run()
|
|
3553
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_user ON notify_deliveries (user_id)').run()
|
|
3554
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_status ON notify_deliveries (email_status)').run()
|
|
3555
|
+
})
|
|
3348
3556
|
}
|
|
3349
3557
|
|
|
3350
3558
|
private static toNotifyDelivery(row: Record<string, unknown>): NotifyDelivery {
|
|
@@ -3476,14 +3684,21 @@ export class D1ServerStore implements ServerStore {
|
|
|
3476
3684
|
|
|
3477
3685
|
/** The worker entry's install: one store per binding, memoized (the
|
|
3478
3686
|
* store is a stateless facade over the binding — safe to share across
|
|
3479
|
-
* the isolate's concurrent requests).
|
|
3687
|
+
* the isolate's concurrent requests). The write budget comes from the
|
|
3688
|
+
* FIRST resolution's opts: the memoized store keeps it (the
|
|
3689
|
+
* deployment's env is constant, so every request passes the same
|
|
3690
|
+
* value — a changed budget needs a fresh binding/isolate). */
|
|
3480
3691
|
const byBinding = new WeakMap<D1Database, D1ServerStore>()
|
|
3481
3692
|
|
|
3482
|
-
export function d1StoreFor(binding: D1Database): D1ServerStore {
|
|
3693
|
+
export function d1StoreFor(binding: D1Database, opts?: D1WriteBudgetOptions): D1ServerStore {
|
|
3483
3694
|
let store = byBinding.get(binding)
|
|
3484
3695
|
if (!store) {
|
|
3485
|
-
store = new D1ServerStore(binding)
|
|
3696
|
+
store = new D1ServerStore(binding, opts)
|
|
3486
3697
|
byBinding.set(binding, store)
|
|
3487
3698
|
}
|
|
3488
3699
|
return store
|
|
3489
3700
|
}
|
|
3701
|
+
|
|
3702
|
+
// The seam's honest-unavailable error, re-exported where the consumer's
|
|
3703
|
+
// route surface already imports the D1 half from.
|
|
3704
|
+
export { StoreUnavailable }
|
|
@@ -903,6 +903,13 @@ export function listOrgMembers(orgId: string): OrgMembership[] {
|
|
|
903
903
|
return rows.map(membershipPayload)
|
|
904
904
|
}
|
|
905
905
|
|
|
906
|
+
export function listAllOrgMemberships(): OrgMembership[] {
|
|
907
|
+
const rows = getDb().prepare(
|
|
908
|
+
'SELECT * FROM org_memberships ORDER BY org_id, created_at',
|
|
909
|
+
).all() as OrgMembershipRow[]
|
|
910
|
+
return rows.map(membershipPayload)
|
|
911
|
+
}
|
|
912
|
+
|
|
906
913
|
export function getOrgMembership(userId: string, orgId: string): OrgMembership | null {
|
|
907
914
|
const row = getDb().prepare('SELECT * FROM org_memberships WHERE user_id = ? AND org_id = ?')
|
|
908
915
|
.get(userId, orgId) as OrgMembershipRow | undefined
|
package/src/store/sqlite.ts
CHANGED
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
listInstrumentRegistrationsForCertificate,
|
|
57
57
|
listInstrumentRegistrationsForHolder,
|
|
58
58
|
listOrgJoinRequests,
|
|
59
|
+
listAllOrgMemberships,
|
|
59
60
|
listOrgMembers,
|
|
60
61
|
listOrgMemberships,
|
|
61
62
|
listOrgRegistryOrgs,
|
|
@@ -420,6 +421,9 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
420
421
|
async listOrgMembers(orgId: string): Promise<OrgMembership[]> {
|
|
421
422
|
return listOrgMembers(orgId)
|
|
422
423
|
},
|
|
424
|
+
async listAllOrgMemberships(): Promise<OrgMembership[]> {
|
|
425
|
+
return listAllOrgMemberships()
|
|
426
|
+
},
|
|
423
427
|
async getOrgMembership(userId: string, orgId: string): Promise<OrgMembership | null> {
|
|
424
428
|
return getOrgMembership(userId, orgId)
|
|
425
429
|
},
|
package/src/store.ts
CHANGED
|
@@ -1354,6 +1354,38 @@ export const DEMO_ACCOUNTS = [
|
|
|
1354
1354
|
|
|
1355
1355
|
export const DEMO_PASSWORD = 'demo2026'
|
|
1356
1356
|
|
|
1357
|
+
/** The store's honest unavailable answer (the 2026-09-01 lesson — a
|
|
1358
|
+
* hung store write must answer in seconds, never spin the caller
|
|
1359
|
+
* forever): a bounded WRITE's confirmation did not arrive within its
|
|
1360
|
+
* budget. The route surface maps it to a 503 naming the store's
|
|
1361
|
+
* TEMPORARY unavailability + the retryability.
|
|
1362
|
+
*
|
|
1363
|
+
* The timed-out write may STILL land — the store accepted the
|
|
1364
|
+
* statement and it was the confirmation path that hung, so the error
|
|
1365
|
+
* never claims the write was lost. `retrySafe` names the statement's
|
|
1366
|
+
* own idempotency posture: true (an upsert, a keyed UPDATE/DELETE, an
|
|
1367
|
+
* IF NOT EXISTS heal) — a retry converges; false (a plain INSERT) —
|
|
1368
|
+
* reconcile before retrying. The reads are NOT bounded by this
|
|
1369
|
+
* discipline (the read-path latency story is read replication); only
|
|
1370
|
+
* write/batch/DDL statements throw this. */
|
|
1371
|
+
export class StoreUnavailable extends Error {
|
|
1372
|
+
constructor(
|
|
1373
|
+
/** The write's label — the verb + the target (e.g.
|
|
1374
|
+
* 'UPDATE sessions', 'batch (6 statements)'); never row data. */
|
|
1375
|
+
readonly operation: string,
|
|
1376
|
+
/** The confirmation budget the write exceeded, in ms. */
|
|
1377
|
+
readonly budgetMs: number,
|
|
1378
|
+
/** The statement's idempotency posture (the class note). */
|
|
1379
|
+
readonly retrySafe: boolean,
|
|
1380
|
+
) {
|
|
1381
|
+
super(
|
|
1382
|
+
`the store write did not confirm within ${budgetMs} ms (${operation}) — the store is briefly unavailable; `
|
|
1383
|
+
+ `the write may have landed — retry is safe for idempotent operations${retrySafe ? ' (this one is)' : ''}`,
|
|
1384
|
+
)
|
|
1385
|
+
this.name = 'StoreUnavailable'
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1357
1389
|
/** The async store contract the routes consume. Every method mirrors a
|
|
1358
1390
|
* sync counterpart in store.ts / entities.ts — same SQL, same
|
|
1359
1391
|
* semantics, awaited. */
|
|
@@ -1965,6 +1997,11 @@ export interface ServerStore {
|
|
|
1965
1997
|
listOrgMemberships(userId: string): Promise<OrgMembership[]>
|
|
1966
1998
|
/** One org's memberships (the per-org view), every state. */
|
|
1967
1999
|
listOrgMembers(orgId: string): Promise<OrgMembership[]>
|
|
2000
|
+
/** EVERY membership across organizations, every state (the admin
|
|
2001
|
+
* registry's org list groups it in memory — one read, never a
|
|
2002
|
+
* per-org loop of listOrgMembers). Org- then creation-ordered, so a
|
|
2003
|
+
* caller's group-by-org keeps listOrgMembers' per-org ordering. */
|
|
2004
|
+
listAllOrgMemberships(): Promise<OrgMembership[]>
|
|
1968
2005
|
getOrgMembership(userId: string, orgId: string): Promise<OrgMembership | null>
|
|
1969
2006
|
/** Create the membership — the org's admin inviting an EXISTING
|
|
1970
2007
|
* account (state 'invited': the holder accepts from the account
|