@oimlsmart/platform-server 0.2.3 → 0.2.5

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.
@@ -0,0 +1,10 @@
1
+ -- Migration 0024 — the SSO wave-A tail (TODO.identity-sso: RP-initiated
2
+ -- logout + prompt=login): the one-time code carries the consenting
3
+ -- session's authentication instant (sessions.created_at, verbatim), so
4
+ -- the token endpoint emits the ID token's auth_time — the forced
5
+ -- re-authentication's freshness proof the RP verifies. NULL = no
6
+ -- instant recorded (a code minted before this wave).
7
+ -- schema.sql carries the same end state for fresh databases —
8
+ -- src/__tests__/d1-store.test.ts pins the UNION of every migration to
9
+ -- schema.sql's CREATE set.
10
+ ALTER TABLE oidc_codes ADD COLUMN auth_time TEXT;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oimlsmart/platform-server",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
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
@@ -640,6 +640,11 @@ export class D1ServerStore implements ServerStore {
640
640
  if (!codeCols.results.some(c => c.name === 'amr')) {
641
641
  await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN amr TEXT').run()
642
642
  }
643
+ // TODO.identity-sso (the wave-A tail): the code carries the
644
+ // consenting session's authentication instant (migration 0024).
645
+ if (!codeCols.results.some(c => c.name === 'auth_time')) {
646
+ await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN auth_time TEXT').run()
647
+ }
643
648
  const tokenCols = await this.db.prepare('PRAGMA table_info(oidc_access_tokens)').all<{ name: string }>()
644
649
  if (!tokenCols.results.some(c => c.name === 'amr')) {
645
650
  await this.db.prepare('ALTER TABLE oidc_access_tokens ADD COLUMN amr TEXT').run()
@@ -862,11 +867,11 @@ export class D1ServerStore implements ServerStore {
862
867
  // reassignment takes effect on the next request; deactivation ends
863
868
  // the session at once.
864
869
  const session = await this.stmt(
865
- `SELECT s.user_id, s.active_org, s.amr, u.email, u.name, u.role, u.roles, u.org_id, u.avatar_url, u.provider, u.email_verified_at
870
+ `SELECT s.user_id, s.active_org, s.amr, s.created_at, u.email, u.name, u.role, u.roles, u.org_id, u.avatar_url, u.provider, u.email_verified_at
866
871
  FROM sessions s JOIN users u ON s.user_id = u.id
867
872
  WHERE s.token = ? AND s.expires_at > datetime('now') AND u.active = 1`,
868
873
  token,
869
- ).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 }>()
874
+ ).first<{ user_id: string; active_org: string | null; amr: string | null; created_at: string; email: string; name: string; role: string; roles: string | null; org_id: string | null; avatar_url: string | null; provider: string; email_verified_at: string | null }>()
870
875
  if (!session) return null
871
876
  const amr = parseRoles(session.amr)
872
877
  const payload: AuthUserPayload = {
@@ -880,6 +885,9 @@ export class D1ServerStore implements ServerStore {
880
885
  provider: session.provider,
881
886
  emailVerifiedAt: session.email_verified_at ?? null,
882
887
  ...(amr?.length ? { amr } : {}),
888
+ // TODO.identity-sso (the wave-A tail): the authentication instant —
889
+ // the ID token's auth_time derives from it (the consumer converts).
890
+ sessionCreatedAt: session.created_at,
883
891
  }
884
892
  // TODO.identity/11: the active-org context (the membership model) —
885
893
  // the payload's org/roles follow the session's stamped context. The
@@ -1295,16 +1303,20 @@ export class D1ServerStore implements ServerStore {
1295
1303
  * (stored as JSON; the token endpoint emits it as the ID token's
1296
1304
  * amr). Absent = no provenance recorded. */
1297
1305
  amr?: string[] | null
1306
+ /** TODO.identity-sso (the wave-A tail): the consenting session's
1307
+ * authentication instant (verbatim; absent = none recorded) — the
1308
+ * token endpoint emits it as the ID token's auth_time. */
1309
+ authTime?: string | null
1298
1310
  ttlMs: number
1299
1311
  }): Promise<void> {
1300
1312
  await this.ensureMembershipSupport()
1301
1313
  const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
1302
1314
  await this.ensureOidcColumns()
1303
1315
  await this.stmt(
1304
- `INSERT INTO oidc_codes (code, client_id, redirect_uri, scope, nonce, code_challenge, user_id, context_org, amr, expires_at)
1305
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1316
+ `INSERT INTO oidc_codes (code, client_id, redirect_uri, scope, nonce, code_challenge, user_id, context_org, amr, auth_time, expires_at)
1317
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1306
1318
  input.code, input.clientId, input.redirectUri, input.scope, input.nonce, input.codeChallenge, input.userId, input.contextOrg ?? null,
1307
- input.amr?.length ? JSON.stringify(input.amr) : null, expiresAt,
1319
+ input.amr?.length ? JSON.stringify(input.amr) : null, input.authTime ?? null, expiresAt,
1308
1320
  ).run()
1309
1321
  }
1310
1322
 
@@ -1330,6 +1342,7 @@ export class D1ServerStore implements ServerStore {
1330
1342
  userId: row.user_id as string,
1331
1343
  contextOrg: (row.context_org as string | null) ?? null,
1332
1344
  amr: parseRoles((row.amr as string | null) ?? null) ?? null,
1345
+ authTime: (row.auth_time as string | null) ?? null,
1333
1346
  expiresAt: row.expires_at as string,
1334
1347
  }
1335
1348
  }
@@ -1996,8 +2009,9 @@ export class D1ServerStore implements ServerStore {
1996
2009
  newEmail: row.new_email as string,
1997
2010
  deliveredBy: row.delivered_by === 'mailer' ? 'mailer' : 'shown',
1998
2011
  // TODO.identity-features/01: rows predating the kind column (or a
1999
- // store over a pre-0022 database) read as the legacy ceremony.
2000
- kind: row.kind === 'add' ? 'add' : 'change',
2012
+ // store over a pre-0022 database) read as the legacy ceremony; the
2013
+ // 0.2.4 'verify' kind reads by its name.
2014
+ kind: row.kind === 'add' ? 'add' : row.kind === 'verify' ? 'verify' : 'change',
2001
2015
  createdAt: row.created_at as string,
2002
2016
  expiresAt: row.expires_at as string,
2003
2017
  consumedAt: (row.consumed_at as string | null) ?? null,
@@ -2009,13 +2023,15 @@ export class D1ServerStore implements ServerStore {
2009
2023
  * pending 'change' rows (only the newest change link works — the
2010
2024
  * pre-01 doctrine); an 'add' request voids the account's earlier
2011
2025
  * pending 'add' rows FOR THE SAME address (other addresses' links
2012
- * stand). */
2026
+ * stand); a 'verify' request voids the account's earlier pending
2027
+ * 'verify' rows (the target is the current primary — one per
2028
+ * account, the change doctrine's scoping). */
2013
2029
  async createEmailChangeToken(input: {
2014
2030
  token: string
2015
2031
  userId: string
2016
2032
  newEmail: string
2017
2033
  deliveredBy: 'mailer' | 'shown'
2018
- kind?: 'change' | 'add'
2034
+ kind?: 'change' | 'add' | 'verify'
2019
2035
  ttlMs: number
2020
2036
  }): Promise<EmailChangeToken> {
2021
2037
  await this.ensureAccountEmailSupport()
@@ -2025,6 +2041,11 @@ export class D1ServerStore implements ServerStore {
2025
2041
  "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'change' AND consumed_at IS NULL",
2026
2042
  input.userId,
2027
2043
  ).run()
2044
+ } else if (kind === 'verify') {
2045
+ await this.stmt(
2046
+ "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'verify' AND consumed_at IS NULL",
2047
+ input.userId,
2048
+ ).run()
2028
2049
  } else {
2029
2050
  await this.stmt(
2030
2051
  "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'add' AND new_email = ? AND consumed_at IS NULL",
@@ -2067,8 +2088,13 @@ export class D1ServerStore implements ServerStore {
2067
2088
  * too, this account's included), then move users.email. 'add' (the
2068
2089
  * per-address verification): the account_emails row landed unverified
2069
2090
  * at the request; the completion stamps it (a row removed meanwhile
2070
- * burns the link as 'unknown'). A 'mailer'-delivered token verifies
2071
- * the address; a shown one never does. */
2091
+ * burns the link as 'unknown'). 'verify' (the 0.2.4 kind): the
2092
+ * re-verification of the address the account ALREADY holds as its
2093
+ * primary — the completion stamps users.email_verified_at when the
2094
+ * token's new_email IS STILL the primary (a primary moved meanwhile
2095
+ * burns the link as 'unknown', the vanished-target doctrine).
2096
+ * A 'mailer'-delivered token verifies the address; a shown one never
2097
+ * does. */
2072
2098
  async completeEmailChange(token: string): Promise<CompleteEmailChangeResult> {
2073
2099
  await this.ensureAccountEmailSupport()
2074
2100
  const res = await this.stmt(
@@ -2084,6 +2110,15 @@ export class D1ServerStore implements ServerStore {
2084
2110
  if (verified) await this.markAccountEmailVerified(row.userId, row.newEmail)
2085
2111
  return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
2086
2112
  }
2113
+ if (row.kind === 'verify') {
2114
+ // The address never changes hands in this ceremony — 'conflict'
2115
+ // does not exist here; the honest burns are the moved primary and
2116
+ // the gone account, both read from the users row.
2117
+ const current = await this.stmt('SELECT email FROM users WHERE id = ?', row.userId).first<{ email: string }>()
2118
+ if (!current || current.email !== row.newEmail) return { kind: 'unknown' }
2119
+ if (verified) await this.stmt("UPDATE users SET email_verified_at = datetime('now') WHERE id = ?", row.userId).run()
2120
+ return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
2121
+ }
2087
2122
  const taken = await this.stmt('SELECT id FROM users WHERE email = ?', row.newEmail).first<{ id: string }>()
2088
2123
  if (taken) return { kind: 'conflict' }
2089
2124
  const takenAdditional = await this.stmt('SELECT user_id FROM account_emails WHERE email = ?', row.newEmail).first<{ user_id: string }>()
@@ -469,8 +469,8 @@ function toEmailChangeToken(row: Record<string, unknown>): EmailChangeToken {
469
469
  newEmail: row.new_email as string,
470
470
  deliveredBy: row.delivered_by === 'mailer' ? 'mailer' : 'shown',
471
471
  // TODO.identity-features/01: rows predating the kind column read as
472
- // the legacy ceremony.
473
- kind: row.kind === 'add' ? 'add' : 'change',
472
+ // the legacy ceremony; the 0.2.4 'verify' kind reads by its name.
473
+ kind: row.kind === 'add' ? 'add' : row.kind === 'verify' ? 'verify' : 'change',
474
474
  createdAt: row.created_at as string,
475
475
  expiresAt: row.expires_at as string,
476
476
  consumedAt: (row.consumed_at as string | null) ?? null,
@@ -482,13 +482,15 @@ function toEmailChangeToken(row: Record<string, unknown>): EmailChangeToken {
482
482
  * pending 'change' rows (only the newest change link works — the
483
483
  * pre-01 doctrine); an 'add' request voids the account's earlier
484
484
  * pending 'add' rows FOR THE SAME address (other addresses' links
485
- * stand). */
485
+ * stand); a 'verify' request voids the account's earlier pending
486
+ * 'verify' rows (the target is the current primary — one per account,
487
+ * the change doctrine's scoping). */
486
488
  export function createEmailChangeToken(input: {
487
489
  token: string
488
490
  userId: string
489
491
  newEmail: string
490
492
  deliveredBy: 'mailer' | 'shown'
491
- kind?: 'change' | 'add'
493
+ kind?: 'change' | 'add' | 'verify'
492
494
  ttlMs: number
493
495
  }): EmailChangeToken {
494
496
  const db = getDb()
@@ -497,6 +499,10 @@ export function createEmailChangeToken(input: {
497
499
  db.prepare(
498
500
  "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'change' AND consumed_at IS NULL",
499
501
  ).run(input.userId)
502
+ } else if (kind === 'verify') {
503
+ db.prepare(
504
+ "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'verify' AND consumed_at IS NULL",
505
+ ).run(input.userId)
500
506
  } else {
501
507
  db.prepare(
502
508
  "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'add' AND new_email = ? AND consumed_at IS NULL",
@@ -535,8 +541,14 @@ export function getPendingEmailChange(userId: string): EmailChangeToken | null {
535
541
  * this account's included), then move users.email. 'add' (the
536
542
  * per-address verification): the account_emails row landed unverified
537
543
  * at the request; the completion stamps it (a row removed meanwhile
538
- * burns the link as 'unknown'). A 'mailer'-delivered token verifies the
539
- * address; a shown one never does. */
544
+ * burns the link as 'unknown'). 'verify' (the 0.2.4 kind): the
545
+ * re-verification of the address the account ALREADY holds as its
546
+ * primary — the completion stamps users.email_verified_at when the
547
+ * token's new_email IS STILL the primary (a primary moved meanwhile —
548
+ * a completed 'change', an admin re-address — burns the link as
549
+ * 'unknown', the vanished-target doctrine; the erasure's token sweep
550
+ * burns it earlier). A 'mailer'-delivered token verifies the address;
551
+ * a shown one never does. */
540
552
  export function completeEmailChange(token: string): CompleteEmailChangeResult {
541
553
  const db = getDb()
542
554
  const res = db.prepare(
@@ -552,6 +564,15 @@ export function completeEmailChange(token: string): CompleteEmailChangeResult {
552
564
  if (verified) markAccountEmailVerified(row.userId, row.newEmail)
553
565
  return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
554
566
  }
567
+ if (row.kind === 'verify') {
568
+ // The address NEVER changes hands in this ceremony — 'conflict' does
569
+ // not exist here; the honest burns are the moved primary and the
570
+ // gone account, both read from the users row.
571
+ const current = db.prepare('SELECT email FROM users WHERE id = ?').get(row.userId) as { email: string } | undefined
572
+ if (!current || current.email !== row.newEmail) return { kind: 'unknown' }
573
+ if (verified) db.prepare("UPDATE users SET email_verified_at = datetime('now') WHERE id = ?").run(row.userId)
574
+ return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
575
+ }
555
576
  const taken = db.prepare('SELECT id FROM users WHERE email = ?').get(row.newEmail) as { id: string } | undefined
556
577
  if (taken) return { kind: 'conflict' }
557
578
  const takenAdditional = db.prepare('SELECT user_id FROM account_emails WHERE email = ?').get(row.newEmail) as { user_id: string } | undefined
@@ -174,14 +174,18 @@ export function createOidcCode(input: {
174
174
  * (stored as JSON; the token endpoint emits it as the ID token's
175
175
  * amr). Absent = no provenance recorded. */
176
176
  amr?: string[] | null
177
+ /** TODO.identity-sso (the wave-A tail): the consenting session's
178
+ * authentication instant (verbatim; absent = none recorded) — the
179
+ * token endpoint emits it as the ID token's auth_time. */
180
+ authTime?: string | null
177
181
  ttlMs: number
178
182
  }): void {
179
183
  const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
180
184
  getDb().prepare(`
181
- INSERT INTO oidc_codes (code, client_id, redirect_uri, scope, nonce, code_challenge, user_id, context_org, amr, expires_at)
182
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
185
+ INSERT INTO oidc_codes (code, client_id, redirect_uri, scope, nonce, code_challenge, user_id, context_org, amr, auth_time, expires_at)
186
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
183
187
  `).run(input.code, input.clientId, input.redirectUri, input.scope, input.nonce, input.codeChallenge, input.userId,
184
- input.contextOrg ?? null, input.amr?.length ? JSON.stringify(input.amr) : null, expiresAt)
188
+ input.contextOrg ?? null, input.amr?.length ? JSON.stringify(input.amr) : null, input.authTime ?? null, expiresAt)
185
189
  }
186
190
 
187
191
  /** Atomically consume the code: the UPDATE flips consumed_at exactly
@@ -204,6 +208,7 @@ export function consumeOidcCode(code: string): OidcCode | null {
204
208
  userId: row.user_id as string,
205
209
  contextOrg: (row.context_org as string | null) ?? null,
206
210
  amr: parseJsonStringList(row.amr),
211
+ authTime: (row.auth_time as string | null) ?? null,
207
212
  expiresAt: row.expires_at as string,
208
213
  }
209
214
  }
@@ -339,6 +339,10 @@ CREATE TABLE IF NOT EXISTS oidc_codes (
339
339
  -- TODO.identity-sso/02+03: the consenting session's amr provenance (a
340
340
  -- JSON array; NULL = none recorded), carried into the ID token.
341
341
  amr TEXT,
342
+ -- TODO.identity-sso (the wave-A tail): the consenting session's
343
+ -- authentication instant (sessions.created_at, verbatim; NULL = none
344
+ -- recorded), carried into the ID token's auth_time.
345
+ auth_time TEXT,
342
346
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
343
347
  expires_at TEXT NOT NULL,
344
348
  consumed_at TEXT
@@ -459,6 +463,11 @@ CREATE INDEX IF NOT EXISTS idx_enrollment_tokens_user ON enrollment_tokens (user
459
463
  -- TODO.identity-features/01: kind names the ceremony — 'change' (the
460
464
  -- primary replacement above) or 'add' (the per-address verification of an
461
465
  -- account_emails row; completion stamps the row's verified_at).
466
+ -- The 0.2.4 kind: 'verify' — the re-verification of the account's CURRENT
467
+ -- primary (new_email carries it as requested; a mailer-delivered
468
+ -- completion stamps users.email_verified_at while the address still IS
469
+ -- the primary). A value, never a column: the kind TEXT carries it, so no
470
+ -- migration rides the 0.2.4 seam change.
462
471
  CREATE TABLE IF NOT EXISTS email_change_tokens (
463
472
  token TEXT PRIMARY KEY,
464
473
  user_id TEXT NOT NULL REFERENCES users(id),
@@ -323,6 +323,10 @@ export function getSessionUser(token: string): AuthUserPayload | null {
323
323
  ).run(token)
324
324
  // s.* carries the SESSION's id — the payload's id is the USER's.
325
325
  const payload = toAuthPayload({ ...session, id: session.user_id })
326
+ // TODO.identity-sso (the wave-A tail): the session's authentication
327
+ // instant (sessions.created_at, verbatim) — the ID token's auth_time
328
+ // derives from it (the consumer converts to the OIDC NumericDate).
329
+ payload.sessionCreatedAt = session.created_at as string
326
330
  // TODO.identity/11: the active-org context (the membership model) —
327
331
  // the payload's org/roles follow the session's stamped context.
328
332
  return applySessionOrgContext(session.active_org ?? null, payload)
@@ -791,7 +791,7 @@ export function createSqliteServerStore(): ServerStore {
791
791
  userId: string
792
792
  newEmail: string
793
793
  deliveredBy: 'mailer' | 'shown'
794
- kind?: 'change' | 'add'
794
+ kind?: 'change' | 'add' | 'verify'
795
795
  ttlMs: number
796
796
  }): Promise<EmailChangeToken> {
797
797
  return createEmailChangeToken(input)
package/src/store.ts CHANGED
@@ -65,6 +65,13 @@ export interface AuthUserPayload {
65
65
  * read (getSessionUser) only; ABSENT = no OP-side credential event
66
66
  * recorded (an upstream-provider sign-in, a legacy row). */
67
67
  amr?: string[]
68
+ /** TODO.identity-sso (the wave-A tail): the SESSION's authentication
69
+ * instant (sessions.created_at, the column verbatim — the sign-in's
70
+ * wall-clock stamp). Projected by the session-backed read
71
+ * (getSessionUser) only. The ID token's auth_time derives from it (the
72
+ * OIDC NumericDate conversion is the consumer's — the column's storage
73
+ * format is the store's own). */
74
+ sessionCreatedAt?: string
68
75
  }
69
76
 
70
77
  // ── identity federation (TODO.federation/10) ─────────────────────────
@@ -425,6 +432,10 @@ export interface OidcCode {
425
432
  /** TODO.identity-sso/02+03: the consenting session's amr provenance
426
433
  * (parsed from the row's JSON; null = none recorded). */
427
434
  amr: string[] | null
435
+ /** TODO.identity-sso (the wave-A tail): the consenting session's
436
+ * authentication instant (sessions.created_at, verbatim; null = none
437
+ * recorded) — the token endpoint emits it as the ID token's auth_time. */
438
+ authTime: string | null
428
439
  expiresAt: string
429
440
  }
430
441
 
@@ -637,13 +648,19 @@ export interface OpAccountErasure {
637
648
  * primary-address replacement; completion moves users.email) or 'add'
638
649
  * (the per-address verification of an account_emails row; completion
639
650
  * stamps the row's verified_at). Rows predating the kind column read
640
- * 'change' (the migration's default). */
651
+ * 'change' (the migration's default).
652
+ * The 0.2.4 kind: 'verify' — the re-verification of the address the
653
+ * account ALREADY holds as its primary (the invited-not-yet-set-up and
654
+ * the admin-re-addressed postures, whose primary never went through a
655
+ * mailbox proof). new_email carries the primary AS REQUESTED;
656
+ * completion stamps users.email_verified_at when the address is STILL
657
+ * the account's primary and the link traveled by mailer. */
641
658
  export interface EmailChangeToken {
642
659
  token: string
643
660
  userId: string
644
661
  newEmail: string
645
662
  deliveredBy: 'mailer' | 'shown'
646
- kind: 'change' | 'add'
663
+ kind: 'change' | 'add' | 'verify'
647
664
  createdAt: string
648
665
  expiresAt: string
649
666
  consumedAt: string | null
@@ -653,7 +670,9 @@ export interface EmailChangeToken {
653
670
  export type CompleteEmailChangeResult =
654
671
  | { kind: 'ok'; userId: string; newEmail: string; verified: boolean }
655
672
  /** Never existed or already consumed (indistinguishable, the
656
- * enrollment rule). */
673
+ * enrollment rule) — or the ceremony's target vanished between
674
+ * request and completion: the 'add' row removed, the 'verify'
675
+ * primary moved (the link burns the same, honestly). */
657
676
  | { kind: 'unknown' }
658
677
  /** Past the TTL: burned on presentation, never redeemable later. */
659
678
  | { kind: 'expired' }
@@ -1572,6 +1591,11 @@ export interface ServerStore {
1572
1591
  * (stored as JSON; the token endpoint emits it as the ID token's
1573
1592
  * amr). Absent = no provenance recorded. */
1574
1593
  amr?: string[] | null
1594
+ /** TODO.identity-sso (the wave-A tail): the consenting session's
1595
+ * authentication instant (sessions.created_at, verbatim; absent =
1596
+ * none recorded) — the token endpoint emits it as the ID token's
1597
+ * auth_time. */
1598
+ authTime?: string | null
1575
1599
  ttlMs: number
1576
1600
  }): Promise<void>
1577
1601
  /** Atomically consume the code: answers the row exactly once (a
@@ -1787,7 +1811,10 @@ export interface ServerStore {
1787
1811
  * account's earlier pending 'change' rows (only the newest change
1788
1812
  * link works — the pre-01 doctrine); an 'add' request voids the
1789
1813
  * account's earlier pending 'add' rows FOR THE SAME address (other
1790
- * addresses' links stand). deliveredBy is stamped at request time and
1814
+ * addresses' links stand); a 'verify' request voids the account's
1815
+ * earlier pending 'verify' rows (the target is the CURRENT primary —
1816
+ * one per account, the change doctrine's scoping; cross-kind links
1817
+ * never touch each other). deliveredBy is stamped at request time and
1791
1818
  * decides whether completion may verify the address. kind defaults
1792
1819
  * 'change'. */
1793
1820
  createEmailChangeToken(input: {
@@ -1795,12 +1822,14 @@ export interface ServerStore {
1795
1822
  userId: string
1796
1823
  newEmail: string
1797
1824
  deliveredBy: 'mailer' | 'shown'
1798
- kind?: 'change' | 'add'
1825
+ kind?: 'change' | 'add' | 'verify'
1799
1826
  ttlMs: number
1800
1827
  }): Promise<EmailChangeToken>
1801
1828
  getEmailChangeToken(token: string): Promise<EmailChangeToken | null>
1802
1829
  /** The account's pending change (the newest unconsumed, unexpired row),
1803
- * so the console can show it. */
1830
+ * so the console can show it. 'verify' rows have NO pending read: the
1831
+ * waiting state IS users.email_verified_at NULL (the 'add' doctrine —
1832
+ * the account_emails rows carry their own). */
1804
1833
  getPendingEmailChange(userId: string): Promise<EmailChangeToken | null>
1805
1834
  /** Complete the ceremony: consume the token ATOMICALLY (a presented
1806
1835
  * link works exactly once, expired or not), judge the expiry, then
@@ -1808,8 +1837,16 @@ export interface ServerStore {
1808
1837
  * BOTH address tables (a conflict burns the token honestly) and moves
1809
1838
  * the account's primary (users.email); 'add' stamps the
1810
1839
  * account_emails row's verified_at (a row removed between request and
1811
- * completion answers 'unknown'). verified = the token traveled by
1812
- * mailer (mailbox proven); a shown link never verifies. */
1840
+ * completion answers 'unknown'); 'verify' (the 0.2.4 kind, the
1841
+ * resend-verification act for the CURRENT primary) stamps
1842
+ * users.email_verified_at when the token's new_email IS STILL the
1843
+ * account's primary — a primary moved meanwhile (a completed 'change',
1844
+ * an admin re-address, the erasure) burns the link as 'unknown', and
1845
+ * 'conflict' never applies (no address changes hands). verified = the
1846
+ * token traveled by mailer (mailbox proven); a shown link never
1847
+ * verifies. The answer carries { userId, newEmail, verified } for
1848
+ * every kind, so the consumer's completion route audits a proven
1849
+ * 'verify' exactly as it audits an 'add' (its account.email_verified). */
1813
1850
  completeEmailChange(token: string): Promise<CompleteEmailChangeResult>
1814
1851
 
1815
1852
  // ── multiple emails per account (TODO.identity-features/01) ──