@oimlsmart/platform-server 0.1.6 → 0.1.8
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/migrations/0021_oidc_consent_grants.sql +44 -0
- package/migrations/0022_account_emails.sql +45 -0
- package/package.json +1 -1
- package/src/store/d1.ts +337 -20
- package/src/store/sqlite/consent-grants-store.ts +85 -0
- package/src/store/sqlite/op-accounts-store.ts +209 -21
- package/src/store/sqlite/schema.sql +50 -0
- package/src/store/sqlite/store.ts +10 -0
- package/src/store/sqlite.ts +48 -1
- package/src/store.ts +166 -12
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- Migration 0021 — the remembered consent grants (TODO.identity-features/12):
|
|
2
|
+
-- the OP remembers the account holder's "Allow" per (user, client, scope
|
|
3
|
+
-- set), so a repeat authorization the grant COVERS skips the consent page
|
|
4
|
+
-- (the OIDC-correct behavior — the page shows again only when the request
|
|
5
|
+
-- carries prompt=consent, when the granted set no longer covers the asked
|
|
6
|
+
-- scopes, or when the holder revoked the access from the account console).
|
|
7
|
+
--
|
|
8
|
+
-- The doctrines:
|
|
9
|
+
-- - ONE LIVE grant per (user_id, client_id, scope): the partial unique
|
|
10
|
+
-- index keys the live rows only (a live grant = revoked_at IS NULL) —
|
|
11
|
+
-- a revoked triple's re-allow lands a FRESH row and the history keeps
|
|
12
|
+
-- the revoked one;
|
|
13
|
+
-- - scope is the CANONICAL spelling (the scope SET, space-joined,
|
|
14
|
+
-- deduped, sorted — store.ts's normalizeOidcScopeSet): 'profile openid'
|
|
15
|
+
-- and 'openid profile' are the same grant, so the unique triple holds
|
|
16
|
+
-- honestly;
|
|
17
|
+
-- - the skip check's COVERAGE math (the granted set ⊇ the requested set)
|
|
18
|
+
-- is the store's (consentGrantCovers over the live rows), never a LIKE
|
|
19
|
+
-- scan in SQL;
|
|
20
|
+
-- - revocation flips revoked_at (the row STAYS — the audit chain carries
|
|
21
|
+
-- the grant + the revoke, the row is their resolvable record); the
|
|
22
|
+
-- account erasure removes the rows outright (a dead account's grants
|
|
23
|
+
-- die with it — the personal_access_tokens doctrine, migration 0020).
|
|
24
|
+
-- schema.sql carries the same end state for fresh databases —
|
|
25
|
+
-- test/migrations.test.ts pins the UNION of every migration to
|
|
26
|
+
-- schema.sql's CREATE set.
|
|
27
|
+
|
|
28
|
+
CREATE TABLE IF NOT EXISTS oidc_consent_grants (
|
|
29
|
+
id TEXT PRIMARY KEY,
|
|
30
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
31
|
+
client_id TEXT NOT NULL,
|
|
32
|
+
-- The granted scope set, the canonical space-joined spelling
|
|
33
|
+
-- (normalizeOidcScopeSet).
|
|
34
|
+
scope TEXT NOT NULL,
|
|
35
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
36
|
+
revoked_at TEXT
|
|
37
|
+
);
|
|
38
|
+
-- One LIVE grant per (user, client, scope set) — the predicate keeps the
|
|
39
|
+
-- revoked rows out of the index, so the re-allow after a revoke inserts
|
|
40
|
+
-- cleanly.
|
|
41
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_oidc_consent_grants_live
|
|
42
|
+
ON oidc_consent_grants (user_id, client_id, scope) WHERE revoked_at IS NULL;
|
|
43
|
+
-- The account console's "apps they can access" read.
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_oidc_consent_grants_user ON oidc_consent_grants (user_id);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
-- Migration 0022 — multiple emails per account (TODO.identity-features/01):
|
|
2
|
+
-- the account gains a primary + additional addresses, each verified
|
|
3
|
+
-- independently, the sign-in and the recovery paths resolving by ANY
|
|
4
|
+
-- verified one.
|
|
5
|
+
--
|
|
6
|
+
-- The model:
|
|
7
|
+
-- - the PRIMARY address stays users.email (+ users.email_verified_at):
|
|
8
|
+
-- the OIDC `email` claim never changes shape, every existing reader
|
|
9
|
+
-- (the claims, the registry, the audit chain) is undisturbed, and the
|
|
10
|
+
-- claims keep carrying the primary on a switch;
|
|
11
|
+
-- - account_emails carries the ADDITIONAL addresses only — one row per
|
|
12
|
+
-- (account, address), verified_at NULL until the per-address ceremony
|
|
13
|
+
-- proves the mailbox. An address is globally unique across the estate
|
|
14
|
+
-- of addresses: the unique index keys it here, and the store's writes
|
|
15
|
+
-- check BOTH tables (an additional on account A blocks the address as
|
|
16
|
+
-- a primary or an additional anywhere else);
|
|
17
|
+
-- - the "primary" attribute is represented by WHERE the address lives
|
|
18
|
+
-- (the users row vs account_emails), never a flag to keep in sync —
|
|
19
|
+
-- the store's setPrimaryAccountEmail swaps the two residences with
|
|
20
|
+
-- their verification stamps;
|
|
21
|
+
-- - email_change_tokens gains `kind`: 'change' (the pre-0022 primary
|
|
22
|
+
-- replacement — the default, so existing rows read honestly) and
|
|
23
|
+
-- 'add' (the per-address verification of an account_emails row; the
|
|
24
|
+
-- row lands unverified at the request and the token's completion
|
|
25
|
+
-- stamps verified_at). The same one-time, 24 h, atomically-consumed
|
|
26
|
+
-- doctrine carries both.
|
|
27
|
+
-- schema.sql carries the same end state for fresh databases —
|
|
28
|
+
-- test/migrations.test.ts pins the UNION of every migration to
|
|
29
|
+
-- schema.sql's CREATE set.
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS account_emails (
|
|
32
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
33
|
+
email TEXT NOT NULL,
|
|
34
|
+
verified_at TEXT,
|
|
35
|
+
added_by TEXT,
|
|
36
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
37
|
+
PRIMARY KEY (user_id, email)
|
|
38
|
+
);
|
|
39
|
+
-- An address names at most ONE account across the estate (the sign-in
|
|
40
|
+
-- and recovery resolutions depend on it); the users.email UNIQUE covers
|
|
41
|
+
-- the primaries, this index the additionals, and the store's writes
|
|
42
|
+
-- check across both.
|
|
43
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_emails_email ON account_emails (email);
|
|
44
|
+
|
|
45
|
+
ALTER TABLE email_change_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'change';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oimlsmart/platform-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
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
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
import type { D1Database, D1PreparedStatement } from '@cloudflare/workers-types'
|
|
22
22
|
import {
|
|
23
23
|
DEMO_PASSWORD,
|
|
24
|
+
type AccountEmail,
|
|
25
|
+
type AddAccountEmailResult,
|
|
24
26
|
type AdvanceCounterResult,
|
|
25
27
|
type AuthUserPayload,
|
|
26
28
|
type CompleteEmailChangeResult,
|
|
@@ -47,7 +49,10 @@ import {
|
|
|
47
49
|
type OidcClient,
|
|
48
50
|
type OidcClientLaunch,
|
|
49
51
|
type OidcCode,
|
|
52
|
+
type OidcConsentGrant,
|
|
50
53
|
type OidcKeyRow,
|
|
54
|
+
consentGrantCovers,
|
|
55
|
+
normalizeOidcScopeSet,
|
|
51
56
|
type OpAccountErasure,
|
|
52
57
|
type OpClientRoleAssignment,
|
|
53
58
|
type OpLiveSession,
|
|
@@ -478,6 +483,65 @@ export class D1ServerStore implements ServerStore {
|
|
|
478
483
|
return this.personalAccessTokenSupportEnsured
|
|
479
484
|
}
|
|
480
485
|
|
|
486
|
+
// TODO.identity-features/12 (the remembered consent grants): the
|
|
487
|
+
// oidc_consent_grants table arrives with migration 0021 — a dev D1
|
|
488
|
+
// migrated from before it lacks the table, so the grant methods ensure
|
|
489
|
+
// it defensively (the ensurePersonalAccessTokenSupport posture,
|
|
490
|
+
// memoized per store).
|
|
491
|
+
private consentGrantSupportEnsured: Promise<void> | null = null
|
|
492
|
+
|
|
493
|
+
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
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// TODO.identity-features/01 (multiple emails per account): the
|
|
516
|
+
// account_emails table + the email_change_tokens.kind column arrive
|
|
517
|
+
// with migration 0022 — a dev D1 migrated from before it lacks both,
|
|
518
|
+
// so the address methods ensure them defensively (the
|
|
519
|
+
// ensureConsentGrantSupport posture, memoized per store).
|
|
520
|
+
private accountEmailSupportEnsured: Promise<void> | null = null
|
|
521
|
+
|
|
522
|
+
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
|
|
543
|
+
}
|
|
544
|
+
|
|
481
545
|
// ── users / sessions ─────────────────────────────────────────────
|
|
482
546
|
|
|
483
547
|
async seedDemoAccounts(): Promise<void> {
|
|
@@ -1125,6 +1189,78 @@ export class D1ServerStore implements ServerStore {
|
|
|
1125
1189
|
).run()
|
|
1126
1190
|
}
|
|
1127
1191
|
|
|
1192
|
+
// ── the remembered consent grants (TODO.identity-features/12) ─────
|
|
1193
|
+
|
|
1194
|
+
private static toConsentGrant(row: Record<string, unknown>): OidcConsentGrant {
|
|
1195
|
+
return {
|
|
1196
|
+
id: row.id as string,
|
|
1197
|
+
userId: row.user_id as string,
|
|
1198
|
+
clientId: row.client_id as string,
|
|
1199
|
+
scope: row.scope as string,
|
|
1200
|
+
createdAt: D1ServerStore.storeTimeToIso(row.created_at as string)!,
|
|
1201
|
+
revokedAt: D1ServerStore.storeTimeToIso((row.revoked_at as string | null) ?? null),
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
async getConsentGrant(userId: string, clientId: string, scope: string): Promise<OidcConsentGrant | null> {
|
|
1206
|
+
await this.ensureConsentGrantSupport()
|
|
1207
|
+
// The skip check's coverage math is the store.ts helper's, never a
|
|
1208
|
+
// LIKE scan: the account's live rows for the client, the freshest
|
|
1209
|
+
// covering grant wins.
|
|
1210
|
+
const res = await this.stmt(
|
|
1211
|
+
'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND client_id = ? AND revoked_at IS NULL ORDER BY created_at DESC, rowid DESC',
|
|
1212
|
+
userId, clientId,
|
|
1213
|
+
).all<Record<string, unknown>>()
|
|
1214
|
+
for (const row of res.results) {
|
|
1215
|
+
if (consentGrantCovers(row.scope as string, scope)) return D1ServerStore.toConsentGrant(row)
|
|
1216
|
+
}
|
|
1217
|
+
return null
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
async recordConsentGrant(input: { userId: string; clientId: string; scope: string }): Promise<OidcConsentGrant> {
|
|
1221
|
+
await this.ensureConsentGrantSupport()
|
|
1222
|
+
const scope = normalizeOidcScopeSet(input.scope)
|
|
1223
|
+
// The upsert targets the partial unique index: a live triple's row
|
|
1224
|
+
// refreshes its stamp (the re-affirmed consent); a REVOKED triple's
|
|
1225
|
+
// re-allow falls out of the index's predicate and inserts FRESH —
|
|
1226
|
+
// the history survives.
|
|
1227
|
+
await this.stmt(
|
|
1228
|
+
`INSERT INTO oidc_consent_grants (id, user_id, client_id, scope)
|
|
1229
|
+
VALUES (?, ?, ?, ?)
|
|
1230
|
+
ON CONFLICT (user_id, client_id, scope) WHERE revoked_at IS NULL
|
|
1231
|
+
DO UPDATE SET created_at = datetime('now')`,
|
|
1232
|
+
crypto.randomUUID(), input.userId, input.clientId, scope,
|
|
1233
|
+
).run()
|
|
1234
|
+
const row = await this.stmt(
|
|
1235
|
+
'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND client_id = ? AND scope = ? AND revoked_at IS NULL',
|
|
1236
|
+
input.userId, input.clientId, scope,
|
|
1237
|
+
).first<Record<string, unknown>>()
|
|
1238
|
+
if (!row) throw new Error('recordConsentGrant: the upsert left no live row')
|
|
1239
|
+
return D1ServerStore.toConsentGrant(row)
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
async listConsentGrants(userId: string): Promise<OidcConsentGrant[]> {
|
|
1243
|
+
await this.ensureConsentGrantSupport()
|
|
1244
|
+
// The console's list: the LIVE grants only (the revoked rows ride the
|
|
1245
|
+
// audit chain), newest first — created_at is second-resolution, the
|
|
1246
|
+
// rowid breaks the tie.
|
|
1247
|
+
const res = await this.stmt(
|
|
1248
|
+
'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC, rowid DESC',
|
|
1249
|
+
userId,
|
|
1250
|
+
).all<Record<string, unknown>>()
|
|
1251
|
+
return res.results.map(D1ServerStore.toConsentGrant)
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/** The guarded revoke: the owner's LIVE row flips, once. */
|
|
1255
|
+
async revokeConsentGrant(id: string, userId: string): Promise<boolean> {
|
|
1256
|
+
await this.ensureConsentGrantSupport()
|
|
1257
|
+
const res = await this.stmt(
|
|
1258
|
+
"UPDATE oidc_consent_grants SET revoked_at = datetime('now') WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
|
|
1259
|
+
id, userId,
|
|
1260
|
+
).run()
|
|
1261
|
+
return (res.meta.changes ?? 0) > 0
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1128
1264
|
// ── the upstream providers (TODO.identity/08) ─────────────────────
|
|
1129
1265
|
// The provider + link rows port directly (D1 is SQLite).
|
|
1130
1266
|
|
|
@@ -1270,6 +1406,13 @@ export class D1ServerStore implements ServerStore {
|
|
|
1270
1406
|
createdBy?: string | null
|
|
1271
1407
|
}): Promise<UserAdminRow | null> {
|
|
1272
1408
|
const id = crypto.randomUUID()
|
|
1409
|
+
// TODO.identity-features/01: the address must be free across BOTH
|
|
1410
|
+
// address tables — an additional on another account blocks the
|
|
1411
|
+
// address as a new account's primary (an address names at most one
|
|
1412
|
+
// account; the users.email UNIQUE remains the race backstop).
|
|
1413
|
+
await this.ensureAccountEmailSupport()
|
|
1414
|
+
const additional = await this.stmt('SELECT user_id FROM account_emails WHERE email = ?', input.email.trim().toLowerCase()).first<{ user_id: string }>()
|
|
1415
|
+
if (additional) return null
|
|
1273
1416
|
try {
|
|
1274
1417
|
await this.stmt(
|
|
1275
1418
|
"INSERT INTO users (id, email, name, provider, role) VALUES (?, ?, ?, 'password', ?)",
|
|
@@ -1284,14 +1427,28 @@ export class D1ServerStore implements ServerStore {
|
|
|
1284
1427
|
}
|
|
1285
1428
|
|
|
1286
1429
|
/** The password sign-in's lookup: the credential + the active flag, by
|
|
1287
|
-
* (normalized) email. The credential's EXISTENCE is the qualifier.
|
|
1430
|
+
* (normalized) email. The credential's EXISTENCE is the qualifier.
|
|
1431
|
+
* TODO.identity-features/01: the address resolves by ANY of the
|
|
1432
|
+
* account's VERIFIED addresses — the primary first (the primary owner
|
|
1433
|
+
* always wins, the deterministic rule), then a proven account_emails
|
|
1434
|
+
* row; an unverified additional never resolves. */
|
|
1288
1435
|
async getPasswordLogin(email: string): Promise<{ userId: string; hash: string; active: boolean } | null> {
|
|
1289
|
-
|
|
1436
|
+
await this.ensureAccountEmailSupport()
|
|
1437
|
+
const normalized = email.trim().toLowerCase()
|
|
1438
|
+
let row = await this.stmt(
|
|
1290
1439
|
`SELECT u.id AS user_id, u.active AS active, p.hash AS hash
|
|
1291
1440
|
FROM users u JOIN passwords p ON p.user_id = u.id
|
|
1292
1441
|
WHERE u.email = ?`,
|
|
1293
|
-
|
|
1442
|
+
normalized,
|
|
1294
1443
|
).first<{ user_id: string; active: number; hash: string }>()
|
|
1444
|
+
if (!row) {
|
|
1445
|
+
row = await this.stmt(
|
|
1446
|
+
`SELECT u.id AS user_id, u.active AS active, p.hash AS hash
|
|
1447
|
+
FROM users u JOIN passwords p ON p.user_id = u.id
|
|
1448
|
+
WHERE u.id = (SELECT user_id FROM account_emails WHERE email = ? AND verified_at IS NOT NULL)`,
|
|
1449
|
+
normalized,
|
|
1450
|
+
).first<{ user_id: string; active: number; hash: string }>()
|
|
1451
|
+
}
|
|
1295
1452
|
if (!row) return null
|
|
1296
1453
|
return { userId: row.user_id, hash: row.hash, active: row.active !== 0 }
|
|
1297
1454
|
}
|
|
@@ -1512,6 +1669,14 @@ export class D1ServerStore implements ServerStore {
|
|
|
1512
1669
|
// again).
|
|
1513
1670
|
await this.ensurePersonalAccessTokenSupport()
|
|
1514
1671
|
const personalAccessTokens = await this.stmt('DELETE FROM personal_access_tokens WHERE user_id = ?', userId).run()
|
|
1672
|
+
// TODO.identity-features/12: the remembered consent grants die with
|
|
1673
|
+
// the account (a tombstone never skips a consent page again).
|
|
1674
|
+
await this.ensureConsentGrantSupport()
|
|
1675
|
+
const consentGrants = await this.stmt('DELETE FROM oidc_consent_grants WHERE user_id = ?', userId).run()
|
|
1676
|
+
// TODO.identity-features/01: the additional addresses die with the
|
|
1677
|
+
// account (a tombstone's addresses never resolve a sign-in again).
|
|
1678
|
+
await this.ensureAccountEmailSupport()
|
|
1679
|
+
const emails = await this.stmt('DELETE FROM account_emails WHERE user_id = ?', userId).run()
|
|
1515
1680
|
await this.stmt(
|
|
1516
1681
|
`UPDATE users SET
|
|
1517
1682
|
email = ?, name = 'Deleted account', provider = 'erased',
|
|
@@ -1529,13 +1694,21 @@ export class D1ServerStore implements ServerStore {
|
|
|
1529
1694
|
factors: (passkeys.meta.changes ?? 0) + (totp.meta.changes ?? 0) + (recovery.meta.changes ?? 0)
|
|
1530
1695
|
+ (challenges.meta.changes ?? 0) + (mfa.meta.changes ?? 0),
|
|
1531
1696
|
personalAccessTokens: personalAccessTokens.meta.changes ?? 0,
|
|
1697
|
+
consentGrants: consentGrants.meta.changes ?? 0,
|
|
1698
|
+
emails: emails.meta.changes ?? 0,
|
|
1532
1699
|
}
|
|
1533
1700
|
}
|
|
1534
1701
|
|
|
1535
1702
|
/** The registry's edit act (name/email). The email UNIQUE conflict
|
|
1536
|
-
* throws 'unique' (the route maps it to a 409, never a silent take).
|
|
1703
|
+
* throws 'unique' (the route maps it to a 409, never a silent take).
|
|
1704
|
+
* TODO.identity-features/01: the conflict read spans BOTH address
|
|
1705
|
+
* tables — an additional row (on any account, this one included)
|
|
1706
|
+
* holds the address too. */
|
|
1537
1707
|
async updateOpAccount(id: string, input: { name?: string; email?: string }): Promise<boolean> {
|
|
1538
1708
|
if (input.email !== undefined) {
|
|
1709
|
+
await this.ensureAccountEmailSupport()
|
|
1710
|
+
const additional = await this.stmt('SELECT user_id FROM account_emails WHERE email = ?', input.email.trim().toLowerCase()).first<{ user_id: string }>()
|
|
1711
|
+
if (additional) throw new Error(`unique: ${input.email}`)
|
|
1539
1712
|
try {
|
|
1540
1713
|
// TODO.identity/06: an admin-set address never went through the
|
|
1541
1714
|
// verify-new-email ceremony, so the verification state resets.
|
|
@@ -1608,29 +1781,46 @@ export class D1ServerStore implements ServerStore {
|
|
|
1608
1781
|
userId: row.user_id as string,
|
|
1609
1782
|
newEmail: row.new_email as string,
|
|
1610
1783
|
deliveredBy: row.delivered_by === 'mailer' ? 'mailer' : 'shown',
|
|
1784
|
+
// TODO.identity-features/01: rows predating the kind column (or a
|
|
1785
|
+
// store over a pre-0022 database) read as the legacy ceremony.
|
|
1786
|
+
kind: row.kind === 'add' ? 'add' : 'change',
|
|
1611
1787
|
createdAt: row.created_at as string,
|
|
1612
1788
|
expiresAt: row.expires_at as string,
|
|
1613
1789
|
consumedAt: (row.consumed_at as string | null) ?? null,
|
|
1614
1790
|
}
|
|
1615
1791
|
}
|
|
1616
1792
|
|
|
1617
|
-
/** Mint the ceremony's token
|
|
1618
|
-
*
|
|
1793
|
+
/** Mint the ceremony's token. The void rule keeps ONE live link per
|
|
1794
|
+
* ceremony target: a 'change' request voids the account's earlier
|
|
1795
|
+
* pending 'change' rows (only the newest change link works — the
|
|
1796
|
+
* pre-01 doctrine); an 'add' request voids the account's earlier
|
|
1797
|
+
* pending 'add' rows FOR THE SAME address (other addresses' links
|
|
1798
|
+
* stand). */
|
|
1619
1799
|
async createEmailChangeToken(input: {
|
|
1620
1800
|
token: string
|
|
1621
1801
|
userId: string
|
|
1622
1802
|
newEmail: string
|
|
1623
1803
|
deliveredBy: 'mailer' | 'shown'
|
|
1804
|
+
kind?: 'change' | 'add'
|
|
1624
1805
|
ttlMs: number
|
|
1625
1806
|
}): Promise<EmailChangeToken> {
|
|
1626
|
-
await this.
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1807
|
+
await this.ensureAccountEmailSupport()
|
|
1808
|
+
const kind = input.kind ?? 'change'
|
|
1809
|
+
if (kind === 'change') {
|
|
1810
|
+
await this.stmt(
|
|
1811
|
+
"UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'change' AND consumed_at IS NULL",
|
|
1812
|
+
input.userId,
|
|
1813
|
+
).run()
|
|
1814
|
+
} else {
|
|
1815
|
+
await this.stmt(
|
|
1816
|
+
"UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'add' AND new_email = ? AND consumed_at IS NULL",
|
|
1817
|
+
input.userId, input.newEmail.trim().toLowerCase(),
|
|
1818
|
+
).run()
|
|
1819
|
+
}
|
|
1630
1820
|
const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
|
|
1631
1821
|
await this.stmt(
|
|
1632
|
-
'INSERT INTO email_change_tokens (token, user_id, new_email, delivered_by, expires_at) VALUES (?, ?, ?, ?, ?)',
|
|
1633
|
-
input.token, input.userId, input.newEmail.trim().toLowerCase(), input.deliveredBy, expiresAt,
|
|
1822
|
+
'INSERT INTO email_change_tokens (token, user_id, new_email, delivered_by, kind, expires_at) VALUES (?, ?, ?, ?, ?, ?)',
|
|
1823
|
+
input.token, input.userId, input.newEmail.trim().toLowerCase(), input.deliveredBy, kind, expiresAt,
|
|
1634
1824
|
).run()
|
|
1635
1825
|
return (await this.getEmailChangeToken(input.token))!
|
|
1636
1826
|
}
|
|
@@ -1640,12 +1830,15 @@ export class D1ServerStore implements ServerStore {
|
|
|
1640
1830
|
return row ? D1ServerStore.toEmailChangeToken(row) : null
|
|
1641
1831
|
}
|
|
1642
1832
|
|
|
1643
|
-
/** The account's pending change (the newest live
|
|
1644
|
-
* can show it.
|
|
1833
|
+
/** The account's pending PRIMARY change (the newest live 'change'
|
|
1834
|
+
* row), so the console can show it. The per-address verifications are
|
|
1835
|
+
* the account_emails rows' own state (verified_at NULL = waiting),
|
|
1836
|
+
* never a pending read here. */
|
|
1645
1837
|
async getPendingEmailChange(userId: string): Promise<EmailChangeToken | null> {
|
|
1838
|
+
await this.ensureAccountEmailSupport()
|
|
1646
1839
|
const row = await this.stmt(
|
|
1647
1840
|
`SELECT * FROM email_change_tokens
|
|
1648
|
-
WHERE user_id = ? AND consumed_at IS NULL AND expires_at > datetime('now')
|
|
1841
|
+
WHERE user_id = ? AND kind = 'change' AND consumed_at IS NULL AND expires_at > datetime('now')
|
|
1649
1842
|
ORDER BY created_at DESC LIMIT 1`,
|
|
1650
1843
|
userId,
|
|
1651
1844
|
).first<Record<string, unknown>>()
|
|
@@ -1653,20 +1846,34 @@ export class D1ServerStore implements ServerStore {
|
|
|
1653
1846
|
}
|
|
1654
1847
|
|
|
1655
1848
|
/** Complete the ceremony: consume ATOMICALLY (a presented link works
|
|
1656
|
-
* exactly once, expired or not), judge the expiry,
|
|
1657
|
-
*
|
|
1658
|
-
*
|
|
1659
|
-
*
|
|
1849
|
+
* exactly once, expired or not), judge the expiry, then act on the
|
|
1850
|
+
* kind. 'change' (the pre-01 primary replacement): re-check the
|
|
1851
|
+
* address's uniqueness across BOTH address tables (a conflict burns
|
|
1852
|
+
* the token honestly — an additional row anywhere holds the address
|
|
1853
|
+
* too, this account's included), then move users.email. 'add' (the
|
|
1854
|
+
* per-address verification): the account_emails row landed unverified
|
|
1855
|
+
* at the request; the completion stamps it (a row removed meanwhile
|
|
1856
|
+
* burns the link as 'unknown'). A 'mailer'-delivered token verifies
|
|
1857
|
+
* the address; a shown one never does. */
|
|
1660
1858
|
async completeEmailChange(token: string): Promise<CompleteEmailChangeResult> {
|
|
1859
|
+
await this.ensureAccountEmailSupport()
|
|
1661
1860
|
const res = await this.stmt(
|
|
1662
1861
|
"UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE token = ? AND consumed_at IS NULL", token,
|
|
1663
1862
|
).run()
|
|
1664
1863
|
if ((res.meta.changes ?? 0) === 0) return { kind: 'unknown' }
|
|
1665
1864
|
const row = (await this.getEmailChangeToken(token))!
|
|
1666
1865
|
if (new Date(row.expiresAt).getTime() <= Date.now()) return { kind: 'expired' }
|
|
1866
|
+
const verified = row.deliveredBy === 'mailer'
|
|
1867
|
+
if (row.kind === 'add') {
|
|
1868
|
+
const standing = await this.stmt('SELECT 1 AS ok FROM account_emails WHERE user_id = ? AND email = ?', row.userId, row.newEmail).first<{ ok: number }>()
|
|
1869
|
+
if (!standing) return { kind: 'unknown' }
|
|
1870
|
+
if (verified) await this.markAccountEmailVerified(row.userId, row.newEmail)
|
|
1871
|
+
return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
|
|
1872
|
+
}
|
|
1667
1873
|
const taken = await this.stmt('SELECT id FROM users WHERE email = ?', row.newEmail).first<{ id: string }>()
|
|
1668
1874
|
if (taken) return { kind: 'conflict' }
|
|
1669
|
-
const
|
|
1875
|
+
const takenAdditional = await this.stmt('SELECT user_id FROM account_emails WHERE email = ?', row.newEmail).first<{ user_id: string }>()
|
|
1876
|
+
if (takenAdditional) return { kind: 'conflict' }
|
|
1670
1877
|
await this.stmt(
|
|
1671
1878
|
`UPDATE users SET email = ?, email_verified_at = ${verified ? "datetime('now')" : 'NULL'} WHERE id = ?`,
|
|
1672
1879
|
row.newEmail, row.userId,
|
|
@@ -1674,6 +1881,116 @@ export class D1ServerStore implements ServerStore {
|
|
|
1674
1881
|
return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
|
|
1675
1882
|
}
|
|
1676
1883
|
|
|
1884
|
+
// ── multiple emails per account (TODO.identity-features/01) ────────
|
|
1885
|
+
|
|
1886
|
+
private static toAccountEmail(row: Record<string, unknown>, isPrimary: boolean): AccountEmail {
|
|
1887
|
+
return {
|
|
1888
|
+
userId: row.user_id as string,
|
|
1889
|
+
email: row.email as string,
|
|
1890
|
+
verifiedAt: (row.verified_at as string | null) ?? null,
|
|
1891
|
+
isPrimary,
|
|
1892
|
+
addedBy: (row.added_by as string | null) ?? null,
|
|
1893
|
+
createdAt: row.created_at as string,
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
/** The account's addresses: the PRIMARY first (the users row's email +
|
|
1898
|
+
* its verification stamp), then the additional account_emails rows
|
|
1899
|
+
* (oldest first). */
|
|
1900
|
+
async listAccountEmails(userId: string): Promise<AccountEmail[]> {
|
|
1901
|
+
await this.ensureAccountEmailSupport()
|
|
1902
|
+
const primary = await this.stmt(
|
|
1903
|
+
'SELECT id AS user_id, email, email_verified_at AS verified_at, created_at FROM users WHERE id = ?', userId,
|
|
1904
|
+
).first<Record<string, unknown>>()
|
|
1905
|
+
const res = await this.stmt(
|
|
1906
|
+
'SELECT * FROM account_emails WHERE user_id = ? ORDER BY created_at, email', userId,
|
|
1907
|
+
).all<Record<string, unknown>>()
|
|
1908
|
+
const out: AccountEmail[] = []
|
|
1909
|
+
if (primary) out.push(D1ServerStore.toAccountEmail(primary, true))
|
|
1910
|
+
out.push(...res.results.map(r => D1ServerStore.toAccountEmail(r, false)))
|
|
1911
|
+
return out
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
/** Resolve the account by ANY of its addresses: the primary always
|
|
1915
|
+
* names it (and the primary owner always wins — the deterministic
|
|
1916
|
+
* rule); an additional ONLY when verified. */
|
|
1917
|
+
async findUserByAnyEmail(email: string): Promise<AuthUserPayload | null> {
|
|
1918
|
+
await this.ensureAccountEmailSupport()
|
|
1919
|
+
const normalized = email.trim().toLowerCase()
|
|
1920
|
+
const primary = await this.stmt('SELECT * FROM users WHERE email = ?', normalized).first<UserRecord>()
|
|
1921
|
+
if (primary) return toPayload(primary)
|
|
1922
|
+
const owner = await this.stmt(
|
|
1923
|
+
'SELECT user_id FROM account_emails WHERE email = ? AND verified_at IS NOT NULL', normalized,
|
|
1924
|
+
).first<{ user_id: string }>()
|
|
1925
|
+
if (!owner) return null
|
|
1926
|
+
const user = await this.stmt('SELECT * FROM users WHERE id = ?', owner.user_id).first<UserRecord>()
|
|
1927
|
+
return user ? toPayload(user) : null
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/** Add an ADDITIONAL address (normalized lowercase; the row lands
|
|
1931
|
+
* UNVERIFIED). The account's own existing row answers 'present' (the
|
|
1932
|
+
* idempotent re-add); any other hold of the address — a primary
|
|
1933
|
+
* anywhere (this account's included) or another account's additional
|
|
1934
|
+
* — answers 'conflict'. The unique index is the race backstop. */
|
|
1935
|
+
async addAccountEmail(userId: string, email: string, addedBy?: string | null): Promise<AddAccountEmailResult> {
|
|
1936
|
+
await this.ensureAccountEmailSupport()
|
|
1937
|
+
const normalized = email.trim().toLowerCase()
|
|
1938
|
+
const takenPrimary = await this.stmt('SELECT id FROM users WHERE email = ?', normalized).first<{ id: string }>()
|
|
1939
|
+
if (takenPrimary) return 'conflict'
|
|
1940
|
+
const existing = await this.stmt('SELECT user_id FROM account_emails WHERE email = ?', normalized).first<{ user_id: string }>()
|
|
1941
|
+
if (existing) return existing.user_id === userId ? 'present' : 'conflict'
|
|
1942
|
+
try {
|
|
1943
|
+
await this.stmt(
|
|
1944
|
+
'INSERT INTO account_emails (user_id, email, added_by) VALUES (?, ?, ?)',
|
|
1945
|
+
userId, normalized, addedBy ?? null,
|
|
1946
|
+
).run()
|
|
1947
|
+
} catch (e) {
|
|
1948
|
+
if (String((e as Error).message).includes('UNIQUE')) return 'conflict'
|
|
1949
|
+
throw e
|
|
1950
|
+
}
|
|
1951
|
+
return 'added'
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/** The verification ceremony's stamp on the account's OWN row: the
|
|
1955
|
+
* guarded UPDATE flips verified_at, once. */
|
|
1956
|
+
async markAccountEmailVerified(userId: string, email: string): Promise<boolean> {
|
|
1957
|
+
await this.ensureAccountEmailSupport()
|
|
1958
|
+
const res = await this.stmt(
|
|
1959
|
+
"UPDATE account_emails SET verified_at = datetime('now') WHERE user_id = ? AND email = ? AND verified_at IS NULL",
|
|
1960
|
+
userId, email.trim().toLowerCase(),
|
|
1961
|
+
).run()
|
|
1962
|
+
return (res.meta.changes ?? 0) > 0
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
/** Promote a VERIFIED additional to primary: the promoted address
|
|
1966
|
+
* becomes users.email with its verification stamp; the outgoing
|
|
1967
|
+
* primary takes the row's place in account_emails with ITS stamp
|
|
1968
|
+
* (it stays a verified additional — sign-in by it keeps working). */
|
|
1969
|
+
async setPrimaryAccountEmail(userId: string, email: string): Promise<'ok' | 'unknown' | 'unverified'> {
|
|
1970
|
+
await this.ensureAccountEmailSupport()
|
|
1971
|
+
const normalized = email.trim().toLowerCase()
|
|
1972
|
+
const row = await this.stmt('SELECT * FROM account_emails WHERE user_id = ? AND email = ?', userId, normalized).first<Record<string, unknown>>()
|
|
1973
|
+
if (!row) return 'unknown'
|
|
1974
|
+
if (!row.verified_at) return 'unverified'
|
|
1975
|
+
const current = await this.stmt('SELECT email, email_verified_at FROM users WHERE id = ?', userId).first<{ email: string; email_verified_at: string | null }>()
|
|
1976
|
+
if (!current) return 'unknown'
|
|
1977
|
+
await this.stmt('UPDATE users SET email = ?, email_verified_at = ? WHERE id = ?', normalized, row.verified_at as string, userId).run()
|
|
1978
|
+
await this.stmt('DELETE FROM account_emails WHERE user_id = ? AND email = ?', userId, normalized).run()
|
|
1979
|
+
await this.stmt('INSERT INTO account_emails (user_id, email, verified_at) VALUES (?, ?, ?)', userId, current.email, current.email_verified_at).run()
|
|
1980
|
+
return 'ok'
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
/** Remove an ADDITIONAL address. The primary refuses honestly
|
|
1984
|
+
* ('primary' — promote another address first). */
|
|
1985
|
+
async removeAccountEmail(userId: string, email: string): Promise<'ok' | 'primary' | 'unknown'> {
|
|
1986
|
+
await this.ensureAccountEmailSupport()
|
|
1987
|
+
const normalized = email.trim().toLowerCase()
|
|
1988
|
+
const current = await this.stmt('SELECT email FROM users WHERE id = ?', userId).first<{ email: string }>()
|
|
1989
|
+
if (current?.email === normalized) return 'primary'
|
|
1990
|
+
const res = await this.stmt('DELETE FROM account_emails WHERE user_id = ? AND email = ?', userId, normalized).run()
|
|
1991
|
+
return (res.meta.changes ?? 0) > 0 ? 'ok' : 'unknown'
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1677
1994
|
// ── strong authentication: the factor registry (TODO.identity-sso/02 + /03) ──
|
|
1678
1995
|
// The same SQL as the SQLite half (store/sqlite/factors-store.ts): the
|
|
1679
1996
|
// one-time consumes are guarded UPDATEs, the counter advance is the
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2
|
+
// The remembered consent grants' SQLite half (TODO.identity-features/12)
|
|
3
|
+
// — the sync implementations behind the ServerStore consent-grant
|
|
4
|
+
// methods (sqlite-server-store.ts delegates here one-for-one, the
|
|
5
|
+
// pat-store.ts pattern). The D1 store implements the same surface in
|
|
6
|
+
// d1.ts.
|
|
7
|
+
//
|
|
8
|
+
// The doctrines carried:
|
|
9
|
+
// - ONE LIVE grant per (user, client, scope set): the partial unique
|
|
10
|
+
// index (migration 0021) is the backstop; the record path's upsert
|
|
11
|
+
// targets it, so a repeat allow refreshes the live row's stamp and a
|
|
12
|
+
// REVOKED triple's re-allow lands a fresh row (the history survives);
|
|
13
|
+
// - the scope cell is the CANONICAL spelling (normalizeOidcScopeSet) —
|
|
14
|
+
// written normalized, read defensively (a hand-edited row still reads
|
|
15
|
+
// as a set, never trusted as a string match);
|
|
16
|
+
// - the revoke is a GUARDED update (the owner's live row flips, once);
|
|
17
|
+
// - the erasure (op-accounts-store.ts's eraseOpAccount) removes the
|
|
18
|
+
// rows outright — a dead account's grants die with it.
|
|
19
|
+
//
|
|
20
|
+
// NODE-ONLY: better-sqlite3 through ./store's getDb. The Worker bundle
|
|
21
|
+
// never sees this module.
|
|
22
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
23
|
+
|
|
24
|
+
import { randomUUID } from 'crypto'
|
|
25
|
+
import { getDb } from './store'
|
|
26
|
+
import { storeTimeToIso } from './factors-store'
|
|
27
|
+
import { consentGrantCovers, normalizeOidcScopeSet, type OidcConsentGrant } from '../../store'
|
|
28
|
+
|
|
29
|
+
function toConsentGrant(row: Record<string, unknown>): OidcConsentGrant {
|
|
30
|
+
return {
|
|
31
|
+
id: row.id as string,
|
|
32
|
+
userId: row.user_id as string,
|
|
33
|
+
clientId: row.client_id as string,
|
|
34
|
+
scope: row.scope as string,
|
|
35
|
+
createdAt: storeTimeToIso(row.created_at as string)!,
|
|
36
|
+
revokedAt: storeTimeToIso((row.revoked_at as string | null) ?? null),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The authorize endpoint's remembered-consent read: the account's LIVE
|
|
41
|
+
* grant for this client whose scope set COVERS the requested set (the
|
|
42
|
+
* freshest first, when several cover). */
|
|
43
|
+
export function getConsentGrant(userId: string, clientId: string, scope: string): OidcConsentGrant | null {
|
|
44
|
+
const rows = getDb().prepare(
|
|
45
|
+
'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND client_id = ? AND revoked_at IS NULL ORDER BY created_at DESC, rowid DESC',
|
|
46
|
+
).all(userId, clientId) as Array<Record<string, unknown>>
|
|
47
|
+
for (const row of rows) {
|
|
48
|
+
if (consentGrantCovers(row.scope as string, scope)) return toConsentGrant(row)
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The consent decision's remember (the allow): the upsert on the live
|
|
54
|
+
* triple refreshes the stamp; a revoked triple's re-allow inserts fresh
|
|
55
|
+
* (the partial unique index's predicate keeps the revoked row out of the
|
|
56
|
+
* collision). Answers the live row. */
|
|
57
|
+
export function recordConsentGrant(input: { userId: string; clientId: string; scope: string }): OidcConsentGrant {
|
|
58
|
+
const scope = normalizeOidcScopeSet(input.scope)
|
|
59
|
+
getDb().prepare(
|
|
60
|
+
`INSERT INTO oidc_consent_grants (id, user_id, client_id, scope)
|
|
61
|
+
VALUES (?, ?, ?, ?)
|
|
62
|
+
ON CONFLICT (user_id, client_id, scope) WHERE revoked_at IS NULL
|
|
63
|
+
DO UPDATE SET created_at = datetime('now')`,
|
|
64
|
+
).run(randomUUID(), input.userId, input.clientId, scope)
|
|
65
|
+
const row = getDb().prepare(
|
|
66
|
+
'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND client_id = ? AND scope = ? AND revoked_at IS NULL',
|
|
67
|
+
).get(input.userId, input.clientId, scope) as Record<string, unknown> | undefined
|
|
68
|
+
if (!row) throw new Error('recordConsentGrant: the upsert left no live row')
|
|
69
|
+
return toConsentGrant(row)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The console's list: the account's LIVE grants, newest first. */
|
|
73
|
+
export function listConsentGrants(userId: string): OidcConsentGrant[] {
|
|
74
|
+
const rows = getDb().prepare(
|
|
75
|
+
'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC, rowid DESC',
|
|
76
|
+
).all(userId) as Array<Record<string, unknown>>
|
|
77
|
+
return rows.map(toConsentGrant)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The guarded revoke: the owner's LIVE row flips, once. */
|
|
81
|
+
export function revokeConsentGrant(id: string, userId: string): boolean {
|
|
82
|
+
return getDb().prepare(
|
|
83
|
+
"UPDATE oidc_consent_grants SET revoked_at = datetime('now') WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
|
|
84
|
+
).run(id, userId).changes > 0
|
|
85
|
+
}
|