@oimlsmart/platform-server 0.1.8 → 0.1.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oimlsmart/platform-server",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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
@@ -188,6 +188,73 @@ function toAdminRow(user: UserRecord & { last_login?: string | null; provider?:
188
188
  * store wipes alongside (its rows reference the wiped events). */
189
189
  const WIPE_TABLES = ['entity_changes', 'evidence_records', 'entities', 'events', 'instrument_registrations', 'notify_deliveries'] as const
190
190
 
191
+ // ── The ensure chains' memo scope (the 2026-09 portal-load audit, R2) ──
192
+ // Every defensive ensure below (a dev-database heal: the PRAGMA probes,
193
+ // the CREATE … IF NOT EXISTS, the idempotent membership backfill) was
194
+ // memoized on the store INSTANCE — but the Worker consumers install a
195
+ // store per request (the binding factory memoizes per binding OBJECT and
196
+ // the Workers runtime hands each request its own env, so the binding's
197
+ // identity is not guaranteed to recur), and the audit measured the
198
+ // ~13-statement chain on every authenticated request (~0.6–0.9 s on the
199
+ // demo hub's D1). The ensures are idempotent by construction, so the
200
+ // honest scope is the (binding, chain) pair held at MODULE scope: the
201
+ // Worker's isolate persists across requests and the consumer pins the
202
+ // binding (the smart repo's server/cloudflare.ts), so the same
203
+ // D1Database object recurs and the chain runs once per isolate. A
204
+ // consumer whose runtime rotates binding objects degrades to the old
205
+ // per-request posture — correct, just unmemoized. A REJECTED ensure
206
+ // evicts itself: the next call retries the heal.
207
+ interface EnsureMemos {
208
+ usersColumns: Promise<void> | null
209
+ sessionColumns: Promise<void> | null
210
+ membershipSupport: Promise<void> | null
211
+ orgRegistrySupport: Promise<void> | null
212
+ holderAttributionSupport: Promise<void> | null
213
+ instrumentRegistrationSupport: Promise<void> | null
214
+ oidcColumns: Promise<void> | null
215
+ personalAccessTokenSupport: Promise<void> | null
216
+ consentGrantSupport: Promise<void> | null
217
+ accountEmailSupport: Promise<void> | null
218
+ notifyDeliverySupport: Promise<void> | null
219
+ }
220
+
221
+ const ensureMemosByBinding = new WeakMap<D1Database, EnsureMemos>()
222
+
223
+ /** Run the chain once per (binding, slot); a rejection evicts itself so
224
+ * the next caller retries. */
225
+ function ensured(binding: D1Database, slot: keyof EnsureMemos, run: () => Promise<void>): Promise<void> {
226
+ let memos = ensureMemosByBinding.get(binding)
227
+ if (!memos) {
228
+ memos = {
229
+ usersColumns: null, sessionColumns: null, membershipSupport: null,
230
+ orgRegistrySupport: null, holderAttributionSupport: null,
231
+ instrumentRegistrationSupport: null, oidcColumns: null,
232
+ personalAccessTokenSupport: null, consentGrantSupport: null,
233
+ accountEmailSupport: null, notifyDeliverySupport: null,
234
+ }
235
+ ensureMemosByBinding.set(binding, memos)
236
+ }
237
+ let pending = memos[slot]
238
+ if (!pending) {
239
+ pending = run()
240
+ memos[slot] = pending
241
+ pending.catch(() => { if (memos[slot] === pending) memos[slot] = null })
242
+ }
243
+ return pending
244
+ }
245
+
246
+ // TODO.identity/06's last-active stamp (getSessionUser's write): the
247
+ // DB-side 60 s WHERE clause stays the source of truth (a second
248
+ // isolate's write still lands through it); the in-isolate cache below
249
+ // simply never ISSUES the write the row would refuse — the audit's R2
250
+ // measured that write on every authenticated request. Keyed on the
251
+ // session token (per-isolate), capped so a long-lived isolate's map
252
+ // stays bounded (a cap reset only re-issues a write the DB clause then
253
+ // throttles — never a correctness change).
254
+ const lastSeenWrites = new Map<string, number>()
255
+ const LAST_SEEN_THROTTLE_MS = 60_000
256
+ const LAST_SEEN_CACHE_CAP = 4096
257
+
191
258
  export class D1ServerStore implements ServerStore {
192
259
  constructor(private readonly db: D1Database) {}
193
260
 
@@ -198,43 +265,34 @@ export class D1ServerStore implements ServerStore {
198
265
  // TODO.federation/12: the roles/active columns arrive with migration
199
266
  // 0002 — a dev D1 migrated from the pre-RBAC 0001 lacks them, so the
200
267
  // user methods ensure them defensively (PRAGMA probe + ALTER),
201
- // memoized per store.
202
- private usersColumnsEnsured: Promise<void> | null = null
203
-
268
+ // memoized per (binding, chain) at module scope (the header note).
204
269
  private ensureUserColumns(): Promise<void> {
205
- if (!this.usersColumnsEnsured) {
206
- this.usersColumnsEnsured = (async () => {
207
- const cols = await this.db.prepare('PRAGMA table_info(users)').all<{ name: string }>()
208
- const names = new Set(cols.results.map(c => c.name))
209
- if (!names.has('roles')) await this.db.prepare('ALTER TABLE users ADD COLUMN roles TEXT').run()
210
- if (!names.has('active')) await this.db.prepare('ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1').run()
211
- // TODO.identity/06 (the account console): the address's
212
- // verification state.
213
- if (!names.has('email_verified_at')) await this.db.prepare('ALTER TABLE users ADD COLUMN email_verified_at TEXT').run()
214
- })()
215
- }
216
- return this.usersColumnsEnsured
270
+ return ensured(this.db, 'usersColumns', async () => {
271
+ const cols = await this.db.prepare('PRAGMA table_info(users)').all<{ name: string }>()
272
+ const names = new Set(cols.results.map(c => c.name))
273
+ if (!names.has('roles')) await this.db.prepare('ALTER TABLE users ADD COLUMN roles TEXT').run()
274
+ if (!names.has('active')) await this.db.prepare('ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1').run()
275
+ // TODO.identity/06 (the account console): the address's
276
+ // verification state.
277
+ if (!names.has('email_verified_at')) await this.db.prepare('ALTER TABLE users ADD COLUMN email_verified_at TEXT').run()
278
+ })
217
279
  }
218
280
 
219
281
  // TODO.identity/06 (the account console's sessions section): the
220
282
  // sign-in context columns arrive with migration 0009 — a dev D1
221
283
  // migrated from before it lacks them, so the session methods ensure
222
- // them defensively (the ensureUserColumns posture, memoized per store).
223
- private sessionColumnsEnsured: Promise<void> | null = null
224
-
284
+ // them defensively (the ensureUserColumns posture, memoized per
285
+ // (binding, chain) at module scope).
225
286
  private ensureSessionColumns(): Promise<void> {
226
- if (!this.sessionColumnsEnsured) {
227
- this.sessionColumnsEnsured = (async () => {
228
- const cols = await this.db.prepare('PRAGMA table_info(sessions)').all<{ name: string }>()
229
- const names = new Set(cols.results.map(c => c.name))
230
- if (!names.has('user_agent')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN user_agent TEXT').run()
231
- if (!names.has('ip')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN ip TEXT').run()
232
- if (!names.has('last_seen_at')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN last_seen_at TEXT').run()
233
- // TODO.identity-sso/02+03: the sign-in provenance.
234
- if (!names.has('amr')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN amr TEXT').run()
235
- })()
236
- }
237
- return this.sessionColumnsEnsured
287
+ return ensured(this.db, 'sessionColumns', async () => {
288
+ const cols = await this.db.prepare('PRAGMA table_info(sessions)').all<{ name: string }>()
289
+ const names = new Set(cols.results.map(c => c.name))
290
+ if (!names.has('user_agent')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN user_agent TEXT').run()
291
+ if (!names.has('ip')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN ip TEXT').run()
292
+ if (!names.has('last_seen_at')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN last_seen_at TEXT').run()
293
+ // TODO.identity-sso/02+03: the sign-in provenance.
294
+ if (!names.has('amr')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN amr TEXT').run()
295
+ })
238
296
  }
239
297
 
240
298
  // TODO.identity/11 (the multi-org membership model): the
@@ -242,304 +300,267 @@ export class D1ServerStore implements ServerStore {
242
300
  // token-flow context columns arrive with migration 0011 — a dev D1
243
301
  // migrated from before it lacks them, so the membership/session/token
244
302
  // methods ensure them defensively (the ensureUserColumns posture,
245
- // memoized per store). The backfill (the migration's twin) is
246
- // IDEMPOTENT and rides the ensure: every org-bound account's primary
247
- // membership mirrors the legacy columns.
248
- private membershipSupportEnsured: Promise<void> | null = null
249
-
303
+ // memoized per (binding, chain) at module scope). The backfill (the
304
+ // migration's twin) is IDEMPOTENT and rides the ensure: every
305
+ // org-bound account's primary membership mirrors the legacy columns.
250
306
  private ensureMembershipSupport(): Promise<void> {
251
- if (!this.membershipSupportEnsured) {
252
- this.membershipSupportEnsured = (async () => {
253
- await this.db.prepare(
254
- `CREATE TABLE IF NOT EXISTS org_memberships (
255
- id TEXT PRIMARY KEY,
256
- user_id TEXT NOT NULL REFERENCES users(id),
257
- org_id TEXT NOT NULL,
258
- roles TEXT NOT NULL DEFAULT '[]',
259
- state TEXT NOT NULL DEFAULT 'active',
260
- is_primary INTEGER NOT NULL DEFAULT 0,
261
- invited_by TEXT,
262
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
263
- activated_at TEXT,
264
- disabled_at TEXT,
265
- disabled_by TEXT,
266
- UNIQUE (user_id, org_id)
267
- )`,
268
- ).run()
269
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_memberships_org ON org_memberships (org_id, state)').run()
270
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_memberships_user ON org_memberships (user_id, state)').run()
271
- // TODO.identity-features/09 (the org-member data cone): the cone
272
- // column arrives with migration 0017 a dev D1 predating it
273
- // grows the column here. NULL = org-wide: existing memberships
274
- // keep their posture silently.
275
- const membershipCols = await this.db.prepare('PRAGMA table_info(org_memberships)').all<{ name: string }>()
276
- if (!membershipCols.results.some(c => c.name === 'cone')) {
277
- await this.db.prepare('ALTER TABLE org_memberships ADD COLUMN cone TEXT').run()
278
- }
279
- const sessionCols = await this.db.prepare('PRAGMA table_info(sessions)').all<{ name: string }>()
280
- if (!sessionCols.results.some(c => c.name === 'active_org')) {
281
- await this.db.prepare('ALTER TABLE sessions ADD COLUMN active_org TEXT').run()
282
- }
283
- const codeCols = await this.db.prepare('PRAGMA table_info(oidc_codes)').all<{ name: string }>()
284
- if (!codeCols.results.some(c => c.name === 'context_org')) {
285
- await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN context_org TEXT').run()
286
- }
287
- const accessCols = await this.db.prepare('PRAGMA table_info(oidc_access_tokens)').all<{ name: string }>()
288
- if (!accessCols.results.some(c => c.name === 'context_org')) {
289
- await this.db.prepare('ALTER TABLE oidc_access_tokens ADD COLUMN context_org TEXT').run()
290
- }
291
- await this.db.prepare(
292
- `INSERT OR IGNORE INTO org_memberships (id, user_id, org_id, roles, state, is_primary, activated_at)
293
- SELECT 'mbr-' || id, id, org_id,
294
- CASE WHEN roles IS NOT NULL AND roles != '' THEN roles ELSE json_array(role) END,
295
- 'active', 1, COALESCE(last_login, created_at)
296
- FROM users WHERE org_id IS NOT NULL`,
297
- ).run()
298
- })()
299
- }
300
- return this.membershipSupportEnsured
307
+ return ensured(this.db, 'membershipSupport', async () => {
308
+ await this.db.prepare(
309
+ `CREATE TABLE IF NOT EXISTS org_memberships (
310
+ id TEXT PRIMARY KEY,
311
+ user_id TEXT NOT NULL REFERENCES users(id),
312
+ org_id TEXT NOT NULL,
313
+ roles TEXT NOT NULL DEFAULT '[]',
314
+ state TEXT NOT NULL DEFAULT 'active',
315
+ is_primary INTEGER NOT NULL DEFAULT 0,
316
+ invited_by TEXT,
317
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
318
+ activated_at TEXT,
319
+ disabled_at TEXT,
320
+ disabled_by TEXT,
321
+ UNIQUE (user_id, org_id)
322
+ )`,
323
+ ).run()
324
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_memberships_org ON org_memberships (org_id, state)').run()
325
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_memberships_user ON org_memberships (user_id, state)').run()
326
+ // TODO.identity-features/09 (the org-member data cone): the cone
327
+ // column arrives with migration 0017 a dev D1 predating it
328
+ // grows the column here. NULL = org-wide: existing memberships
329
+ // keep their posture silently.
330
+ const membershipCols = await this.db.prepare('PRAGMA table_info(org_memberships)').all<{ name: string }>()
331
+ if (!membershipCols.results.some(c => c.name === 'cone')) {
332
+ await this.db.prepare('ALTER TABLE org_memberships ADD COLUMN cone TEXT').run()
333
+ }
334
+ const sessionCols = await this.db.prepare('PRAGMA table_info(sessions)').all<{ name: string }>()
335
+ if (!sessionCols.results.some(c => c.name === 'active_org')) {
336
+ await this.db.prepare('ALTER TABLE sessions ADD COLUMN active_org TEXT').run()
337
+ }
338
+ const codeCols = await this.db.prepare('PRAGMA table_info(oidc_codes)').all<{ name: string }>()
339
+ if (!codeCols.results.some(c => c.name === 'context_org')) {
340
+ await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN context_org TEXT').run()
341
+ }
342
+ const accessCols = await this.db.prepare('PRAGMA table_info(oidc_access_tokens)').all<{ name: string }>()
343
+ if (!accessCols.results.some(c => c.name === 'context_org')) {
344
+ await this.db.prepare('ALTER TABLE oidc_access_tokens ADD COLUMN context_org TEXT').run()
345
+ }
346
+ await this.db.prepare(
347
+ `INSERT OR IGNORE INTO org_memberships (id, user_id, org_id, roles, state, is_primary, activated_at)
348
+ SELECT 'mbr-' || id, id, org_id,
349
+ CASE WHEN roles IS NOT NULL AND roles != '' THEN roles ELSE json_array(role) END,
350
+ 'active', 1, COALESCE(last_login, created_at)
351
+ FROM users WHERE org_id IS NOT NULL`,
352
+ ).run()
353
+ })
301
354
  }
302
355
 
303
356
  // TODO.identity-features/05 (the organization registry): the
304
357
  // org_registry table arrives with migration 0013 — a dev D1 migrated
305
358
  // from before it lacks the table, so the registry methods ensure it
306
359
  // defensively (the ensureMembershipSupport posture, memoized per
307
- // store).
308
- private orgRegistrySupportEnsured: Promise<void> | null = null
309
-
360
+ // (binding, chain) at module scope).
310
361
  private ensureOrgRegistrySupport(): Promise<void> {
311
- if (!this.orgRegistrySupportEnsured) {
312
- this.orgRegistrySupportEnsured = (async () => {
313
- await this.db.prepare(
314
- `CREATE TABLE IF NOT EXISTS org_registry (
315
- id TEXT PRIMARY KEY,
316
- name TEXT NOT NULL,
317
- short_name TEXT,
318
- kind TEXT,
319
- country TEXT,
320
- contacts TEXT NOT NULL DEFAULT '[]',
321
- participant_ref TEXT,
322
- state TEXT NOT NULL DEFAULT 'active',
323
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
324
- created_by TEXT,
325
- updated_at TEXT,
326
- updated_by TEXT,
327
- disabled_at TEXT,
328
- disabled_by TEXT
329
- )`,
330
- ).run()
331
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_registry_state ON org_registry (state)').run()
332
- // TODO.identity-features/10 (the OIML Member category): the
333
- // designation links + the CS status facet arrive with migration
334
- // 0019 a dev D1 predating it grows the columns here (the
335
- // PRAGMA probe + ALTER posture of ensureUserColumns). NULL = not
336
- // recorded: existing rows keep their posture silently.
337
- const cols = await this.db.prepare('PRAGMA table_info(org_registry)').all<{ name: string }>()
338
- const names = new Set(cols.results.map(c => c.name))
339
- if (!names.has('designated_by')) await this.db.prepare('ALTER TABLE org_registry ADD COLUMN designated_by TEXT').run()
340
- if (!names.has('proposed_by')) await this.db.prepare('ALTER TABLE org_registry ADD COLUMN proposed_by TEXT').run()
341
- if (!names.has('cs_status')) await this.db.prepare('ALTER TABLE org_registry ADD COLUMN cs_status TEXT').run()
342
- })()
343
- }
344
- return this.orgRegistrySupportEnsured
362
+ return ensured(this.db, 'orgRegistrySupport', async () => {
363
+ await this.db.prepare(
364
+ `CREATE TABLE IF NOT EXISTS org_registry (
365
+ id TEXT PRIMARY KEY,
366
+ name TEXT NOT NULL,
367
+ short_name TEXT,
368
+ kind TEXT,
369
+ country TEXT,
370
+ contacts TEXT NOT NULL DEFAULT '[]',
371
+ participant_ref TEXT,
372
+ state TEXT NOT NULL DEFAULT 'active',
373
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
374
+ created_by TEXT,
375
+ updated_at TEXT,
376
+ updated_by TEXT,
377
+ disabled_at TEXT,
378
+ disabled_by TEXT
379
+ )`,
380
+ ).run()
381
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_registry_state ON org_registry (state)').run()
382
+ // TODO.identity-features/10 (the OIML Member category): the
383
+ // designation links + the CS status facet arrive with migration
384
+ // 0019 a dev D1 predating it grows the columns here (the
385
+ // PRAGMA probe + ALTER posture of ensureUserColumns). NULL = not
386
+ // recorded: existing rows keep their posture silently.
387
+ const cols = await this.db.prepare('PRAGMA table_info(org_registry)').all<{ name: string }>()
388
+ const names = new Set(cols.results.map(c => c.name))
389
+ if (!names.has('designated_by')) await this.db.prepare('ALTER TABLE org_registry ADD COLUMN designated_by TEXT').run()
390
+ if (!names.has('proposed_by')) await this.db.prepare('ALTER TABLE org_registry ADD COLUMN proposed_by TEXT').run()
391
+ if (!names.has('cs_status')) await this.db.prepare('ALTER TABLE org_registry ADD COLUMN cs_status TEXT').run()
392
+ })
345
393
  }
346
394
 
347
395
  // TODO.register/02 (the register's holder-org attribution): the
348
396
  // certificate_holder_orgs / certificate_holder_claims tables arrive with
349
397
  // migration 0015 — a dev D1 migrated from before it lacks them, so the
350
398
  // attribution/claim methods ensure them defensively (the
351
- // ensureOrgRegistrySupport posture, memoized per store).
352
- private holderAttributionSupportEnsured: Promise<void> | null = null
353
-
399
+ // ensureOrgRegistrySupport posture, memoized per (binding, chain) at
400
+ // module scope).
354
401
  private ensureHolderAttributionSupport(): Promise<void> {
355
- if (!this.holderAttributionSupportEnsured) {
356
- this.holderAttributionSupportEnsured = (async () => {
357
- await this.db.prepare(
358
- `CREATE TABLE IF NOT EXISTS certificate_holder_orgs (
359
- certificate_id TEXT PRIMARY KEY,
360
- org_id TEXT NOT NULL,
361
- org_name TEXT NOT NULL,
362
- source TEXT NOT NULL,
363
- attributed_at TEXT NOT NULL,
364
- attributed_by TEXT,
365
- claim_id TEXT
366
- )`,
367
- ).run()
368
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_orgs_org ON certificate_holder_orgs (org_id)').run()
369
- await this.db.prepare(
370
- `CREATE TABLE IF NOT EXISTS certificate_holder_claims (
371
- id TEXT PRIMARY KEY,
372
- certificate_id TEXT NOT NULL,
373
- claimant_org_id TEXT NOT NULL,
374
- claimant_org_name TEXT NOT NULL,
375
- matched_holder_name TEXT NOT NULL,
376
- claimed_by TEXT NOT NULL,
377
- state TEXT NOT NULL DEFAULT 'pending',
378
- decided_by TEXT,
379
- decided_at TEXT,
380
- refusal_reason TEXT,
381
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
382
- )`,
383
- ).run()
384
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_cert ON certificate_holder_claims (certificate_id)').run()
385
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_state ON certificate_holder_claims (state)').run()
386
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_org ON certificate_holder_claims (claimant_org_id)').run()
387
- })()
388
- }
389
- return this.holderAttributionSupportEnsured
402
+ return ensured(this.db, 'holderAttributionSupport', async () => {
403
+ await this.db.prepare(
404
+ `CREATE TABLE IF NOT EXISTS certificate_holder_orgs (
405
+ certificate_id TEXT PRIMARY KEY,
406
+ org_id TEXT NOT NULL,
407
+ org_name TEXT NOT NULL,
408
+ source TEXT NOT NULL,
409
+ attributed_at TEXT NOT NULL,
410
+ attributed_by TEXT,
411
+ claim_id TEXT
412
+ )`,
413
+ ).run()
414
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_orgs_org ON certificate_holder_orgs (org_id)').run()
415
+ await this.db.prepare(
416
+ `CREATE TABLE IF NOT EXISTS certificate_holder_claims (
417
+ id TEXT PRIMARY KEY,
418
+ certificate_id TEXT NOT NULL,
419
+ claimant_org_id TEXT NOT NULL,
420
+ claimant_org_name TEXT NOT NULL,
421
+ matched_holder_name TEXT NOT NULL,
422
+ claimed_by TEXT NOT NULL,
423
+ state TEXT NOT NULL DEFAULT 'pending',
424
+ decided_by TEXT,
425
+ decided_at TEXT,
426
+ refusal_reason TEXT,
427
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
428
+ )`,
429
+ ).run()
430
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_cert ON certificate_holder_claims (certificate_id)').run()
431
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_state ON certificate_holder_claims (state)').run()
432
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_org ON certificate_holder_claims (claimant_org_id)').run()
433
+ })
390
434
  }
391
435
 
392
436
  // TODO.register/03 (the instrument register): the
393
437
  // instrument_registrations table arrives with migration 0016 — a dev
394
438
  // D1 migrated from before it lacks the table, so the register methods
395
439
  // ensure it defensively (the ensureOrgRegistrySupport posture,
396
- // memoized per store).
397
- private instrumentRegistrationSupportEnsured: Promise<void> | null = null
398
-
440
+ // memoized per (binding, chain) at module scope).
399
441
  private ensureInstrumentRegistrationSupport(): Promise<void> {
400
- if (!this.instrumentRegistrationSupportEnsured) {
401
- this.instrumentRegistrationSupportEnsured = (async () => {
402
- await this.db.prepare(
403
- `CREATE TABLE IF NOT EXISTS instrument_registrations (
404
- id TEXT PRIMARY KEY,
405
- certificate_id TEXT NOT NULL,
406
- holder_org_id TEXT NOT NULL,
407
- standard_id TEXT NOT NULL,
408
- serial_number TEXT NOT NULL,
409
- manufacture_date TEXT,
410
- designations TEXT NOT NULL DEFAULT '{}',
411
- scope_status TEXT NOT NULL,
412
- scope_detail TEXT,
413
- lifecycle TEXT NOT NULL DEFAULT 'registered',
414
- registered_at TEXT NOT NULL DEFAULT (datetime('now')),
415
- registered_by TEXT,
416
- updated_at TEXT,
417
- updated_by TEXT,
418
- UNIQUE (certificate_id, serial_number)
419
- )`,
420
- ).run()
421
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_certificate ON instrument_registrations (certificate_id)').run()
422
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_holder ON instrument_registrations (holder_org_id)').run()
423
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_lifecycle ON instrument_registrations (lifecycle)').run()
424
- })()
425
- }
426
- return this.instrumentRegistrationSupportEnsured
442
+ return ensured(this.db, 'instrumentRegistrationSupport', async () => {
443
+ await this.db.prepare(
444
+ `CREATE TABLE IF NOT EXISTS instrument_registrations (
445
+ id TEXT PRIMARY KEY,
446
+ certificate_id TEXT NOT NULL,
447
+ holder_org_id TEXT NOT NULL,
448
+ standard_id TEXT NOT NULL,
449
+ serial_number TEXT NOT NULL,
450
+ manufacture_date TEXT,
451
+ designations TEXT NOT NULL DEFAULT '{}',
452
+ scope_status TEXT NOT NULL,
453
+ scope_detail TEXT,
454
+ lifecycle TEXT NOT NULL DEFAULT 'registered',
455
+ registered_at TEXT NOT NULL DEFAULT (datetime('now')),
456
+ registered_by TEXT,
457
+ updated_at TEXT,
458
+ updated_by TEXT,
459
+ UNIQUE (certificate_id, serial_number)
460
+ )`,
461
+ ).run()
462
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_certificate ON instrument_registrations (certificate_id)').run()
463
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_holder ON instrument_registrations (holder_org_id)').run()
464
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_lifecycle ON instrument_registrations (lifecycle)').run()
465
+ })
427
466
  }
428
467
 
429
468
  // TODO.identity-sso/02+03: the amr provenance columns on the OIDC
430
469
  // 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 before the wave never 500s the core flow.
433
- private oidcColumnsEnsured: Promise<void> | null = null
434
-
470
+ // defensively (the same memoized posture as the session/user columns,
471
+ // per (binding, chain) at module scope), so a dev D1 migrated from
472
+ // before the wave never 500s the core flow.
435
473
  private ensureOidcColumns(): Promise<void> {
436
- if (!this.oidcColumnsEnsured) {
437
- this.oidcColumnsEnsured = (async () => {
438
- const codeCols = await this.db.prepare('PRAGMA table_info(oidc_codes)').all<{ name: string }>()
439
- if (!codeCols.results.some(c => c.name === 'amr')) {
440
- await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN amr TEXT').run()
441
- }
442
- const tokenCols = await this.db.prepare('PRAGMA table_info(oidc_access_tokens)').all<{ name: string }>()
443
- if (!tokenCols.results.some(c => c.name === 'amr')) {
444
- await this.db.prepare('ALTER TABLE oidc_access_tokens ADD COLUMN amr TEXT').run()
445
- }
446
- })()
447
- }
448
- return this.oidcColumnsEnsured
474
+ return ensured(this.db, 'oidcColumns', async () => {
475
+ const codeCols = await this.db.prepare('PRAGMA table_info(oidc_codes)').all<{ name: string }>()
476
+ if (!codeCols.results.some(c => c.name === 'amr')) {
477
+ await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN amr TEXT').run()
478
+ }
479
+ const tokenCols = await this.db.prepare('PRAGMA table_info(oidc_access_tokens)').all<{ name: string }>()
480
+ if (!tokenCols.results.some(c => c.name === 'amr')) {
481
+ await this.db.prepare('ALTER TABLE oidc_access_tokens ADD COLUMN amr TEXT').run()
482
+ }
483
+ })
449
484
  }
450
485
 
451
486
  // TODO.identity-features/08 (the personal access tokens): the
452
487
  // personal_access_tokens table arrives with migration 0020 — a dev D1
453
488
  // migrated from before it lacks the table, so the PAT methods ensure
454
489
  // it defensively (the ensureOrgRegistrySupport posture, memoized per
455
- // store).
456
- private personalAccessTokenSupportEnsured: Promise<void> | null = null
457
-
490
+ // (binding, chain) at module scope).
458
491
  private ensurePersonalAccessTokenSupport(): Promise<void> {
459
- if (!this.personalAccessTokenSupportEnsured) {
460
- this.personalAccessTokenSupportEnsured = (async () => {
461
- await this.db.prepare(
462
- `CREATE TABLE IF NOT EXISTS personal_access_tokens (
463
- id TEXT PRIMARY KEY,
464
- user_id TEXT NOT NULL REFERENCES users(id),
465
- name TEXT NOT NULL,
466
- token_hash TEXT NOT NULL,
467
- token_prefix TEXT NOT NULL,
468
- scopes TEXT NOT NULL DEFAULT '[]',
469
- org_context TEXT,
470
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
471
- expires_at TEXT NOT NULL,
472
- last_used_at TEXT,
473
- last_exchange_audit_at TEXT,
474
- expiry_notified_at TEXT,
475
- revoked_at TEXT,
476
- revoked_by TEXT,
477
- UNIQUE (token_hash)
478
- )`,
479
- ).run()
480
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_personal_access_tokens_user ON personal_access_tokens (user_id)').run()
481
- })()
482
- }
483
- return this.personalAccessTokenSupportEnsured
492
+ return ensured(this.db, 'personalAccessTokenSupport', async () => {
493
+ await this.db.prepare(
494
+ `CREATE TABLE IF NOT EXISTS personal_access_tokens (
495
+ id TEXT PRIMARY KEY,
496
+ user_id TEXT NOT NULL REFERENCES users(id),
497
+ name TEXT NOT NULL,
498
+ token_hash TEXT NOT NULL,
499
+ token_prefix TEXT NOT NULL,
500
+ scopes TEXT NOT NULL DEFAULT '[]',
501
+ org_context TEXT,
502
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
503
+ expires_at TEXT NOT NULL,
504
+ last_used_at TEXT,
505
+ last_exchange_audit_at TEXT,
506
+ expiry_notified_at TEXT,
507
+ revoked_at TEXT,
508
+ revoked_by TEXT,
509
+ UNIQUE (token_hash)
510
+ )`,
511
+ ).run()
512
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_personal_access_tokens_user ON personal_access_tokens (user_id)').run()
513
+ })
484
514
  }
485
515
 
486
516
  // TODO.identity-features/12 (the remembered consent grants): the
487
517
  // oidc_consent_grants table arrives with migration 0021 — a dev D1
488
518
  // migrated from before it lacks the table, so the grant methods ensure
489
519
  // it defensively (the ensurePersonalAccessTokenSupport posture,
490
- // memoized per store).
491
- private consentGrantSupportEnsured: Promise<void> | null = null
492
-
520
+ // memoized per (binding, chain) at module scope).
493
521
  private ensureConsentGrantSupport(): Promise<void> {
494
- if (!this.consentGrantSupportEnsured) {
495
- this.consentGrantSupportEnsured = (async () => {
496
- await this.db.prepare(
497
- `CREATE TABLE IF NOT EXISTS oidc_consent_grants (
498
- id TEXT PRIMARY KEY,
499
- user_id TEXT NOT NULL REFERENCES users(id),
500
- client_id TEXT NOT NULL,
501
- scope TEXT NOT NULL,
502
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
503
- revoked_at TEXT
504
- )`,
505
- ).run()
506
- await this.db.prepare(
507
- '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',
508
- ).run()
509
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_oidc_consent_grants_user ON oidc_consent_grants (user_id)').run()
510
- })()
511
- }
512
- return this.consentGrantSupportEnsured
522
+ return ensured(this.db, 'consentGrantSupport', async () => {
523
+ await this.db.prepare(
524
+ `CREATE TABLE IF NOT EXISTS oidc_consent_grants (
525
+ id TEXT PRIMARY KEY,
526
+ user_id TEXT NOT NULL REFERENCES users(id),
527
+ client_id TEXT NOT NULL,
528
+ scope TEXT NOT NULL,
529
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
530
+ revoked_at TEXT
531
+ )`,
532
+ ).run()
533
+ await this.db.prepare(
534
+ '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',
535
+ ).run()
536
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_oidc_consent_grants_user ON oidc_consent_grants (user_id)').run()
537
+ })
513
538
  }
514
539
 
515
540
  // TODO.identity-features/01 (multiple emails per account): the
516
541
  // account_emails table + the email_change_tokens.kind column arrive
517
542
  // with migration 0022 — a dev D1 migrated from before it lacks both,
518
543
  // so the address methods ensure them defensively (the
519
- // ensureConsentGrantSupport posture, memoized per store).
520
- private accountEmailSupportEnsured: Promise<void> | null = null
521
-
544
+ // ensureConsentGrantSupport posture, memoized per (binding, chain) at
545
+ // module scope).
522
546
  private ensureAccountEmailSupport(): Promise<void> {
523
- if (!this.accountEmailSupportEnsured) {
524
- this.accountEmailSupportEnsured = (async () => {
525
- await this.db.prepare(
526
- `CREATE TABLE IF NOT EXISTS account_emails (
527
- user_id TEXT NOT NULL REFERENCES users(id),
528
- email TEXT NOT NULL,
529
- verified_at TEXT,
530
- added_by TEXT,
531
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
532
- PRIMARY KEY (user_id, email)
533
- )`,
534
- ).run()
535
- await this.db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_account_emails_email ON account_emails (email)').run()
536
- const tokenCols = await this.db.prepare('PRAGMA table_info(email_change_tokens)').all<{ name: string }>()
537
- if (!tokenCols.results.some(c => c.name === 'kind')) {
538
- await this.db.prepare("ALTER TABLE email_change_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'change'").run()
539
- }
540
- })()
541
- }
542
- return this.accountEmailSupportEnsured
547
+ return ensured(this.db, 'accountEmailSupport', async () => {
548
+ await this.db.prepare(
549
+ `CREATE TABLE IF NOT EXISTS account_emails (
550
+ user_id TEXT NOT NULL REFERENCES users(id),
551
+ email TEXT NOT NULL,
552
+ verified_at TEXT,
553
+ added_by TEXT,
554
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
555
+ PRIMARY KEY (user_id, email)
556
+ )`,
557
+ ).run()
558
+ await this.db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_account_emails_email ON account_emails (email)').run()
559
+ const tokenCols = await this.db.prepare('PRAGMA table_info(email_change_tokens)').all<{ name: string }>()
560
+ if (!tokenCols.results.some(c => c.name === 'kind')) {
561
+ await this.db.prepare("ALTER TABLE email_change_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'change'").run()
562
+ }
563
+ })
543
564
  }
544
565
 
545
566
  // ── users / sessions ─────────────────────────────────────────────
@@ -648,7 +669,28 @@ export class D1ServerStore implements ServerStore {
648
669
  return row?.id_token_hint ?? null
649
670
  }
650
671
 
672
+ /** TODO.identity/06's last-active stamp, throttled to one ISSUED write
673
+ * per minute per session per isolate — the DB-side 60 s WHERE clause
674
+ * stays the source of truth across isolates (the module-scope note);
675
+ * the in-isolate cache only skips issuing a write the row would
676
+ * refuse. A failed write is never cached (the cache set follows the
677
+ * await), so the next request retries. */
678
+ private async stampSessionLastSeen(token: string): Promise<void> {
679
+ const now = Date.now()
680
+ if (now - (lastSeenWrites.get(token) ?? 0) < LAST_SEEN_THROTTLE_MS) return
681
+ await this.stmt(
682
+ "UPDATE sessions SET last_seen_at = datetime('now') WHERE token = ? AND (last_seen_at IS NULL OR last_seen_at < datetime('now', '-60 seconds'))",
683
+ token,
684
+ ).run()
685
+ if (lastSeenWrites.size >= LAST_SEEN_CACHE_CAP) lastSeenWrites.clear()
686
+ lastSeenWrites.set(token, now)
687
+ }
688
+
651
689
  async getSessionUser(token: string): Promise<AuthUserPayload | null> {
690
+ // The ensure chains are memoized per (binding, chain) at module scope
691
+ // (the header note) — past the isolate's first request these three
692
+ // awaits are memo hits, and the per-request work that remains is the
693
+ // session read itself.
652
694
  await this.ensureUserColumns()
653
695
  await this.ensureSessionColumns()
654
696
  await this.ensureMembershipSupport()
@@ -662,12 +704,6 @@ export class D1ServerStore implements ServerStore {
662
704
  token,
663
705
  ).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
706
  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
707
  const amr = parseRoles(session.amr)
672
708
  const payload: AuthUserPayload = {
673
709
  id: session.user_id,
@@ -682,10 +718,16 @@ export class D1ServerStore implements ServerStore {
682
718
  ...(amr?.length ? { amr } : {}),
683
719
  }
684
720
  // TODO.identity/11: the active-org context (the membership model) —
685
- // the payload's org/roles follow the session's stamped context.
721
+ // the payload's org/roles follow the session's stamped context. The
722
+ // three post-JOIN statements (the throttled last-active stamp, the
723
+ // two membership reads) are independent, so they round-trip together
724
+ // (the audit's R2: sequential statements are the latency).
686
725
  const activeOrg = session.active_org ?? null
687
- const active = activeOrg ? await this.getOrgMembership(payload.id, activeOrg) : null
688
- const primary = payload.orgId ? await this.getOrgMembership(payload.id, payload.orgId) : null
726
+ const [, active, primary] = await Promise.all([
727
+ this.stampSessionLastSeen(token),
728
+ activeOrg ? this.getOrgMembership(payload.id, activeOrg) : null,
729
+ payload.orgId ? this.getOrgMembership(payload.id, payload.orgId) : null,
730
+ ])
689
731
  const resolved = resolveOrgContext(payload, { activeOrg, active, primary })
690
732
  if (activeOrg && !(active && active.state === 'active')) {
691
733
  // The stale stamp never lingers (the membership ended mid-session).
@@ -2526,6 +2568,14 @@ export class D1ServerStore implements ServerStore {
2526
2568
  return res.results.map(D1ServerStore.toOrgMembership)
2527
2569
  }
2528
2570
 
2571
+ async listAllOrgMemberships(): Promise<OrgMembership[]> {
2572
+ await this.ensureMembershipSupport()
2573
+ const res = await this.stmt(
2574
+ 'SELECT * FROM org_memberships ORDER BY org_id, created_at',
2575
+ ).all<Record<string, unknown>>()
2576
+ return res.results.map(D1ServerStore.toOrgMembership)
2577
+ }
2578
+
2529
2579
  async getOrgMembership(userId: string, orgId: string): Promise<OrgMembership | null> {
2530
2580
  await this.ensureMembershipSupport()
2531
2581
  const row = await this.stmt('SELECT * FROM org_memberships WHERE user_id = ? AND org_id = ?', userId, orgId)
@@ -3320,31 +3370,27 @@ export class D1ServerStore implements ServerStore {
3320
3370
  // The SAME statements as sqlite/notify.ts's sync half (D1 is SQLite).
3321
3371
  // The defensive ensure mirrors the instrument_registrations posture:
3322
3372
  // a dev D1 migrated from before migration 0018 lacks the table.
3323
-
3324
- private notifyDeliverySupportEnsured: Promise<void> | null = null
3373
+ // (Memoized per (binding, chain) at module scope — the header note.)
3325
3374
 
3326
3375
  private ensureNotifyDeliverySupport(): Promise<void> {
3327
- if (!this.notifyDeliverySupportEnsured) {
3328
- this.notifyDeliverySupportEnsured = (async () => {
3329
- await this.db.prepare(
3330
- `CREATE TABLE IF NOT EXISTS notify_deliveries (
3331
- id TEXT PRIMARY KEY,
3332
- event_id TEXT NOT NULL,
3333
- user_id TEXT NOT NULL,
3334
- reason TEXT NOT NULL,
3335
- email TEXT NOT NULL,
3336
- email_status TEXT,
3337
- email_at TEXT,
3338
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
3339
- UNIQUE (event_id, user_id)
3340
- )`,
3341
- ).run()
3342
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_event ON notify_deliveries (event_id)').run()
3343
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_user ON notify_deliveries (user_id)').run()
3344
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_status ON notify_deliveries (email_status)').run()
3345
- })()
3346
- }
3347
- return this.notifyDeliverySupportEnsured
3376
+ return ensured(this.db, 'notifyDeliverySupport', async () => {
3377
+ await this.db.prepare(
3378
+ `CREATE TABLE IF NOT EXISTS notify_deliveries (
3379
+ id TEXT PRIMARY KEY,
3380
+ event_id TEXT NOT NULL,
3381
+ user_id TEXT NOT NULL,
3382
+ reason TEXT NOT NULL,
3383
+ email TEXT NOT NULL,
3384
+ email_status TEXT,
3385
+ email_at TEXT,
3386
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
3387
+ UNIQUE (event_id, user_id)
3388
+ )`,
3389
+ ).run()
3390
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_event ON notify_deliveries (event_id)').run()
3391
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_user ON notify_deliveries (user_id)').run()
3392
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_status ON notify_deliveries (email_status)').run()
3393
+ })
3348
3394
  }
3349
3395
 
3350
3396
  private static toNotifyDelivery(row: Record<string, unknown>): NotifyDelivery {
@@ -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
@@ -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
@@ -1965,6 +1965,11 @@ export interface ServerStore {
1965
1965
  listOrgMemberships(userId: string): Promise<OrgMembership[]>
1966
1966
  /** One org's memberships (the per-org view), every state. */
1967
1967
  listOrgMembers(orgId: string): Promise<OrgMembership[]>
1968
+ /** EVERY membership across organizations, every state (the admin
1969
+ * registry's org list groups it in memory — one read, never a
1970
+ * per-org loop of listOrgMembers). Org- then creation-ordered, so a
1971
+ * caller's group-by-org keeps listOrgMembers' per-org ordering. */
1972
+ listAllOrgMemberships(): Promise<OrgMembership[]>
1968
1973
  getOrgMembership(userId: string, orgId: string): Promise<OrgMembership | null>
1969
1974
  /** Create the membership — the org's admin inviting an EXISTING
1970
1975
  * account (state 'invited': the holder accepts from the account