@oimlsmart/platform-server 0.2.2 → 0.2.4

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.2.2",
3
+ "version": "0.2.4",
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
@@ -1996,8 +1996,9 @@ export class D1ServerStore implements ServerStore {
1996
1996
  newEmail: row.new_email as string,
1997
1997
  deliveredBy: row.delivered_by === 'mailer' ? 'mailer' : 'shown',
1998
1998
  // 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',
1999
+ // store over a pre-0022 database) read as the legacy ceremony; the
2000
+ // 0.2.4 'verify' kind reads by its name.
2001
+ kind: row.kind === 'add' ? 'add' : row.kind === 'verify' ? 'verify' : 'change',
2001
2002
  createdAt: row.created_at as string,
2002
2003
  expiresAt: row.expires_at as string,
2003
2004
  consumedAt: (row.consumed_at as string | null) ?? null,
@@ -2009,13 +2010,15 @@ export class D1ServerStore implements ServerStore {
2009
2010
  * pending 'change' rows (only the newest change link works — the
2010
2011
  * pre-01 doctrine); an 'add' request voids the account's earlier
2011
2012
  * pending 'add' rows FOR THE SAME address (other addresses' links
2012
- * stand). */
2013
+ * stand); a 'verify' request voids the account's earlier pending
2014
+ * 'verify' rows (the target is the current primary — one per
2015
+ * account, the change doctrine's scoping). */
2013
2016
  async createEmailChangeToken(input: {
2014
2017
  token: string
2015
2018
  userId: string
2016
2019
  newEmail: string
2017
2020
  deliveredBy: 'mailer' | 'shown'
2018
- kind?: 'change' | 'add'
2021
+ kind?: 'change' | 'add' | 'verify'
2019
2022
  ttlMs: number
2020
2023
  }): Promise<EmailChangeToken> {
2021
2024
  await this.ensureAccountEmailSupport()
@@ -2025,6 +2028,11 @@ export class D1ServerStore implements ServerStore {
2025
2028
  "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'change' AND consumed_at IS NULL",
2026
2029
  input.userId,
2027
2030
  ).run()
2031
+ } else if (kind === 'verify') {
2032
+ await this.stmt(
2033
+ "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'verify' AND consumed_at IS NULL",
2034
+ input.userId,
2035
+ ).run()
2028
2036
  } else {
2029
2037
  await this.stmt(
2030
2038
  "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 +2075,13 @@ export class D1ServerStore implements ServerStore {
2067
2075
  * too, this account's included), then move users.email. 'add' (the
2068
2076
  * per-address verification): the account_emails row landed unverified
2069
2077
  * 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. */
2078
+ * burns the link as 'unknown'). 'verify' (the 0.2.4 kind): the
2079
+ * re-verification of the address the account ALREADY holds as its
2080
+ * primary — the completion stamps users.email_verified_at when the
2081
+ * token's new_email IS STILL the primary (a primary moved meanwhile
2082
+ * burns the link as 'unknown', the vanished-target doctrine).
2083
+ * A 'mailer'-delivered token verifies the address; a shown one never
2084
+ * does. */
2072
2085
  async completeEmailChange(token: string): Promise<CompleteEmailChangeResult> {
2073
2086
  await this.ensureAccountEmailSupport()
2074
2087
  const res = await this.stmt(
@@ -2084,6 +2097,15 @@ export class D1ServerStore implements ServerStore {
2084
2097
  if (verified) await this.markAccountEmailVerified(row.userId, row.newEmail)
2085
2098
  return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
2086
2099
  }
2100
+ if (row.kind === 'verify') {
2101
+ // The address never changes hands in this ceremony — 'conflict'
2102
+ // does not exist here; the honest burns are the moved primary and
2103
+ // the gone account, both read from the users row.
2104
+ const current = await this.stmt('SELECT email FROM users WHERE id = ?', row.userId).first<{ email: string }>()
2105
+ if (!current || current.email !== row.newEmail) return { kind: 'unknown' }
2106
+ if (verified) await this.stmt("UPDATE users SET email_verified_at = datetime('now') WHERE id = ?", row.userId).run()
2107
+ return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
2108
+ }
2087
2109
  const taken = await this.stmt('SELECT id FROM users WHERE email = ?', row.newEmail).first<{ id: string }>()
2088
2110
  if (taken) return { kind: 'conflict' }
2089
2111
  const takenAdditional = await this.stmt('SELECT user_id FROM account_emails WHERE email = ?', row.newEmail).first<{ user_id: string }>()
@@ -3235,8 +3257,16 @@ export class D1ServerStore implements ServerStore {
3235
3257
  // ── the workflow entity store + change journal ───────────────────
3236
3258
 
3237
3259
  async listEntities(store: string): Promise<EntityRow[]> {
3260
+ // The ORDER BY is the seam's contract (the 0.2.3 pin): the answer
3261
+ // arrives in (org_id, rowid) order — the read's observable order
3262
+ // since migration 0001, when the planner walked
3263
+ // idx_entities_store_org. Migration 0023's expression index offered
3264
+ // a second store-prefixed walk and the unnamed order flipped to
3265
+ // insertion (the smart app's render-baseline red, oimlsmart/smart
3266
+ // PR #264) — a list read's order is a consumer-visible contract,
3267
+ // never the planner's pick.
3238
3268
  const res = await this.stmt(
3239
- 'SELECT store, id, org_id, data, updated_at FROM entities WHERE store = ?', store,
3269
+ 'SELECT store, id, org_id, data, updated_at FROM entities WHERE store = ? ORDER BY org_id, rowid', store,
3240
3270
  ).all<EntityRow>()
3241
3271
  return res.results
3242
3272
  }
@@ -21,8 +21,13 @@ export { ORG_FIELDS, CATALOG_STORES, orgIdOf } from '../../store'
21
21
  import type { EntityRow, EntityChange } from '../../store'
22
22
 
23
23
  export function listEntities(store: string): EntityRow[] {
24
+ // The ORDER BY is the seam's contract (the 0.2.3 pin, the D1 half's
25
+ // twin): (org_id, rowid) — the read's observable order since
26
+ // migration 0001's idx_entities_store_org walk, made planner-proof
27
+ // when migration 0023's expression index offered a second
28
+ // store-prefixed plan.
24
29
  return getDb()
25
- .prepare('SELECT store, id, org_id, data, updated_at FROM entities WHERE store = ?')
30
+ .prepare('SELECT store, id, org_id, data, updated_at FROM entities WHERE store = ? ORDER BY org_id, rowid')
26
31
  .all(store) as EntityRow[]
27
32
  }
28
33
 
@@ -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
@@ -459,6 +459,11 @@ CREATE INDEX IF NOT EXISTS idx_enrollment_tokens_user ON enrollment_tokens (user
459
459
  -- TODO.identity-features/01: kind names the ceremony — 'change' (the
460
460
  -- primary replacement above) or 'add' (the per-address verification of an
461
461
  -- account_emails row; completion stamps the row's verified_at).
462
+ -- The 0.2.4 kind: 'verify' — the re-verification of the account's CURRENT
463
+ -- primary (new_email carries it as requested; a mailer-delivered
464
+ -- completion stamps users.email_verified_at while the address still IS
465
+ -- the primary). A value, never a column: the kind TEXT carries it, so no
466
+ -- migration rides the 0.2.4 seam change.
462
467
  CREATE TABLE IF NOT EXISTS email_change_tokens (
463
468
  token TEXT PRIMARY KEY,
464
469
  user_id TEXT NOT NULL REFERENCES users(id),
@@ -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
@@ -637,13 +637,19 @@ export interface OpAccountErasure {
637
637
  * primary-address replacement; completion moves users.email) or 'add'
638
638
  * (the per-address verification of an account_emails row; completion
639
639
  * stamps the row's verified_at). Rows predating the kind column read
640
- * 'change' (the migration's default). */
640
+ * 'change' (the migration's default).
641
+ * The 0.2.4 kind: 'verify' — the re-verification of the address the
642
+ * account ALREADY holds as its primary (the invited-not-yet-set-up and
643
+ * the admin-re-addressed postures, whose primary never went through a
644
+ * mailbox proof). new_email carries the primary AS REQUESTED;
645
+ * completion stamps users.email_verified_at when the address is STILL
646
+ * the account's primary and the link traveled by mailer. */
641
647
  export interface EmailChangeToken {
642
648
  token: string
643
649
  userId: string
644
650
  newEmail: string
645
651
  deliveredBy: 'mailer' | 'shown'
646
- kind: 'change' | 'add'
652
+ kind: 'change' | 'add' | 'verify'
647
653
  createdAt: string
648
654
  expiresAt: string
649
655
  consumedAt: string | null
@@ -653,7 +659,9 @@ export interface EmailChangeToken {
653
659
  export type CompleteEmailChangeResult =
654
660
  | { kind: 'ok'; userId: string; newEmail: string; verified: boolean }
655
661
  /** Never existed or already consumed (indistinguishable, the
656
- * enrollment rule). */
662
+ * enrollment rule) — or the ceremony's target vanished between
663
+ * request and completion: the 'add' row removed, the 'verify'
664
+ * primary moved (the link burns the same, honestly). */
657
665
  | { kind: 'unknown' }
658
666
  /** Past the TTL: burned on presentation, never redeemable later. */
659
667
  | { kind: 'expired' }
@@ -1787,7 +1795,10 @@ export interface ServerStore {
1787
1795
  * account's earlier pending 'change' rows (only the newest change
1788
1796
  * link works — the pre-01 doctrine); an 'add' request voids the
1789
1797
  * account's earlier pending 'add' rows FOR THE SAME address (other
1790
- * addresses' links stand). deliveredBy is stamped at request time and
1798
+ * addresses' links stand); a 'verify' request voids the account's
1799
+ * earlier pending 'verify' rows (the target is the CURRENT primary —
1800
+ * one per account, the change doctrine's scoping; cross-kind links
1801
+ * never touch each other). deliveredBy is stamped at request time and
1791
1802
  * decides whether completion may verify the address. kind defaults
1792
1803
  * 'change'. */
1793
1804
  createEmailChangeToken(input: {
@@ -1795,12 +1806,14 @@ export interface ServerStore {
1795
1806
  userId: string
1796
1807
  newEmail: string
1797
1808
  deliveredBy: 'mailer' | 'shown'
1798
- kind?: 'change' | 'add'
1809
+ kind?: 'change' | 'add' | 'verify'
1799
1810
  ttlMs: number
1800
1811
  }): Promise<EmailChangeToken>
1801
1812
  getEmailChangeToken(token: string): Promise<EmailChangeToken | null>
1802
1813
  /** The account's pending change (the newest unconsumed, unexpired row),
1803
- * so the console can show it. */
1814
+ * so the console can show it. 'verify' rows have NO pending read: the
1815
+ * waiting state IS users.email_verified_at NULL (the 'add' doctrine —
1816
+ * the account_emails rows carry their own). */
1804
1817
  getPendingEmailChange(userId: string): Promise<EmailChangeToken | null>
1805
1818
  /** Complete the ceremony: consume the token ATOMICALLY (a presented
1806
1819
  * link works exactly once, expired or not), judge the expiry, then
@@ -1808,8 +1821,16 @@ export interface ServerStore {
1808
1821
  * BOTH address tables (a conflict burns the token honestly) and moves
1809
1822
  * the account's primary (users.email); 'add' stamps the
1810
1823
  * 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. */
1824
+ * completion answers 'unknown'); 'verify' (the 0.2.4 kind, the
1825
+ * resend-verification act for the CURRENT primary) stamps
1826
+ * users.email_verified_at when the token's new_email IS STILL the
1827
+ * account's primary — a primary moved meanwhile (a completed 'change',
1828
+ * an admin re-address, the erasure) burns the link as 'unknown', and
1829
+ * 'conflict' never applies (no address changes hands). verified = the
1830
+ * token traveled by mailer (mailbox proven); a shown link never
1831
+ * verifies. The answer carries { userId, newEmail, verified } for
1832
+ * every kind, so the consumer's completion route audits a proven
1833
+ * 'verify' exactly as it audits an 'add' (its account.email_verified). */
1813
1834
  completeEmailChange(token: string): Promise<CompleteEmailChangeResult>
1814
1835
 
1815
1836
  // ── multiple emails per account (TODO.identity-features/01) ──
@@ -2221,6 +2242,12 @@ export interface ServerStore {
2221
2242
  ): Promise<InstrumentRegistration | null>
2222
2243
 
2223
2244
  // ── the workflow entity store + change journal ──
2245
+ /** The store's rows, in the seam's declared order: (org_id, rowid) —
2246
+ * the read's observable order since migration 0001 (NULL org ids
2247
+ * first, then by org id, then by insertion). Both backends spell the
2248
+ * ORDER BY explicitly — a list read's order is a consumer-visible
2249
+ * contract, never the planner's pick (the 0.2.3 pin, after
2250
+ * migration 0023's expression index flipped the unnamed walk). */
2224
2251
  listEntities(store: string): Promise<EntityRow[]>
2225
2252
  getEntity(store: string, id: string): Promise<EntityRow | undefined>
2226
2253
  putEntity(store: string, id: string, orgId: string | null, data: string): Promise<void>