@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.
|
|
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
|
-
|
|
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').
|
|
2071
|
-
* the address
|
|
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').
|
|
539
|
-
* address
|
|
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),
|
package/src/store/sqlite.ts
CHANGED
|
@@ -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)
|
|
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')
|
|
1812
|
-
*
|
|
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>
|