@oimlsmart/platform-server 0.1.6 → 0.1.7

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,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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oimlsmart/platform-server",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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
@@ -47,7 +47,10 @@ import {
47
47
  type OidcClient,
48
48
  type OidcClientLaunch,
49
49
  type OidcCode,
50
+ type OidcConsentGrant,
50
51
  type OidcKeyRow,
52
+ consentGrantCovers,
53
+ normalizeOidcScopeSet,
51
54
  type OpAccountErasure,
52
55
  type OpClientRoleAssignment,
53
56
  type OpLiveSession,
@@ -478,6 +481,35 @@ export class D1ServerStore implements ServerStore {
478
481
  return this.personalAccessTokenSupportEnsured
479
482
  }
480
483
 
484
+ // TODO.identity-features/12 (the remembered consent grants): the
485
+ // oidc_consent_grants table arrives with migration 0021 — a dev D1
486
+ // migrated from before it lacks the table, so the grant methods ensure
487
+ // it defensively (the ensurePersonalAccessTokenSupport posture,
488
+ // memoized per store).
489
+ private consentGrantSupportEnsured: Promise<void> | null = null
490
+
491
+ private ensureConsentGrantSupport(): Promise<void> {
492
+ if (!this.consentGrantSupportEnsured) {
493
+ this.consentGrantSupportEnsured = (async () => {
494
+ await this.db.prepare(
495
+ `CREATE TABLE IF NOT EXISTS oidc_consent_grants (
496
+ id TEXT PRIMARY KEY,
497
+ user_id TEXT NOT NULL REFERENCES users(id),
498
+ client_id TEXT NOT NULL,
499
+ scope TEXT NOT NULL,
500
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
501
+ revoked_at TEXT
502
+ )`,
503
+ ).run()
504
+ await this.db.prepare(
505
+ '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',
506
+ ).run()
507
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_oidc_consent_grants_user ON oidc_consent_grants (user_id)').run()
508
+ })()
509
+ }
510
+ return this.consentGrantSupportEnsured
511
+ }
512
+
481
513
  // ── users / sessions ─────────────────────────────────────────────
482
514
 
483
515
  async seedDemoAccounts(): Promise<void> {
@@ -1125,6 +1157,78 @@ export class D1ServerStore implements ServerStore {
1125
1157
  ).run()
1126
1158
  }
1127
1159
 
1160
+ // ── the remembered consent grants (TODO.identity-features/12) ─────
1161
+
1162
+ private static toConsentGrant(row: Record<string, unknown>): OidcConsentGrant {
1163
+ return {
1164
+ id: row.id as string,
1165
+ userId: row.user_id as string,
1166
+ clientId: row.client_id as string,
1167
+ scope: row.scope as string,
1168
+ createdAt: D1ServerStore.storeTimeToIso(row.created_at as string)!,
1169
+ revokedAt: D1ServerStore.storeTimeToIso((row.revoked_at as string | null) ?? null),
1170
+ }
1171
+ }
1172
+
1173
+ async getConsentGrant(userId: string, clientId: string, scope: string): Promise<OidcConsentGrant | null> {
1174
+ await this.ensureConsentGrantSupport()
1175
+ // The skip check's coverage math is the store.ts helper's, never a
1176
+ // LIKE scan: the account's live rows for the client, the freshest
1177
+ // covering grant wins.
1178
+ const res = await this.stmt(
1179
+ 'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND client_id = ? AND revoked_at IS NULL ORDER BY created_at DESC, rowid DESC',
1180
+ userId, clientId,
1181
+ ).all<Record<string, unknown>>()
1182
+ for (const row of res.results) {
1183
+ if (consentGrantCovers(row.scope as string, scope)) return D1ServerStore.toConsentGrant(row)
1184
+ }
1185
+ return null
1186
+ }
1187
+
1188
+ async recordConsentGrant(input: { userId: string; clientId: string; scope: string }): Promise<OidcConsentGrant> {
1189
+ await this.ensureConsentGrantSupport()
1190
+ const scope = normalizeOidcScopeSet(input.scope)
1191
+ // The upsert targets the partial unique index: a live triple's row
1192
+ // refreshes its stamp (the re-affirmed consent); a REVOKED triple's
1193
+ // re-allow falls out of the index's predicate and inserts FRESH —
1194
+ // the history survives.
1195
+ await this.stmt(
1196
+ `INSERT INTO oidc_consent_grants (id, user_id, client_id, scope)
1197
+ VALUES (?, ?, ?, ?)
1198
+ ON CONFLICT (user_id, client_id, scope) WHERE revoked_at IS NULL
1199
+ DO UPDATE SET created_at = datetime('now')`,
1200
+ crypto.randomUUID(), input.userId, input.clientId, scope,
1201
+ ).run()
1202
+ const row = await this.stmt(
1203
+ 'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND client_id = ? AND scope = ? AND revoked_at IS NULL',
1204
+ input.userId, input.clientId, scope,
1205
+ ).first<Record<string, unknown>>()
1206
+ if (!row) throw new Error('recordConsentGrant: the upsert left no live row')
1207
+ return D1ServerStore.toConsentGrant(row)
1208
+ }
1209
+
1210
+ async listConsentGrants(userId: string): Promise<OidcConsentGrant[]> {
1211
+ await this.ensureConsentGrantSupport()
1212
+ // The console's list: the LIVE grants only (the revoked rows ride the
1213
+ // audit chain), newest first — created_at is second-resolution, the
1214
+ // rowid breaks the tie.
1215
+ const res = await this.stmt(
1216
+ 'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC, rowid DESC',
1217
+ userId,
1218
+ ).all<Record<string, unknown>>()
1219
+ return res.results.map(D1ServerStore.toConsentGrant)
1220
+ }
1221
+
1222
+ /** The guarded revoke: the owner's LIVE row flips, once. */
1223
+ async revokeConsentGrant(id: string, userId: string): Promise<boolean> {
1224
+ await this.ensureConsentGrantSupport()
1225
+ const res = await this.stmt(
1226
+ "UPDATE oidc_consent_grants SET revoked_at = datetime('now') WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
1227
+ id, userId,
1228
+ ).run()
1229
+ return (res.meta.changes ?? 0) > 0
1230
+ }
1231
+
1128
1232
  // ── the upstream providers (TODO.identity/08) ─────────────────────
1129
1233
  // The provider + link rows port directly (D1 is SQLite).
1130
1234
 
@@ -1512,6 +1616,10 @@ export class D1ServerStore implements ServerStore {
1512
1616
  // again).
1513
1617
  await this.ensurePersonalAccessTokenSupport()
1514
1618
  const personalAccessTokens = await this.stmt('DELETE FROM personal_access_tokens WHERE user_id = ?', userId).run()
1619
+ // TODO.identity-features/12: the remembered consent grants die with
1620
+ // the account (a tombstone never skips a consent page again).
1621
+ await this.ensureConsentGrantSupport()
1622
+ const consentGrants = await this.stmt('DELETE FROM oidc_consent_grants WHERE user_id = ?', userId).run()
1515
1623
  await this.stmt(
1516
1624
  `UPDATE users SET
1517
1625
  email = ?, name = 'Deleted account', provider = 'erased',
@@ -1529,6 +1637,7 @@ export class D1ServerStore implements ServerStore {
1529
1637
  factors: (passkeys.meta.changes ?? 0) + (totp.meta.changes ?? 0) + (recovery.meta.changes ?? 0)
1530
1638
  + (challenges.meta.changes ?? 0) + (mfa.meta.changes ?? 0),
1531
1639
  personalAccessTokens: personalAccessTokens.meta.changes ?? 0,
1640
+ consentGrants: consentGrants.meta.changes ?? 0,
1532
1641
  }
1533
1642
  }
1534
1643
 
@@ -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
+ }
@@ -313,6 +313,7 @@ export function eraseOpAccount(userId: string): {
313
313
  tokens: number
314
314
  factors: number
315
315
  personalAccessTokens: number
316
+ consentGrants: number
316
317
  } | null {
317
318
  const db = getDb()
318
319
  const row = db.prepare('SELECT 1 AS ok FROM users WHERE id = ?').get(userId)
@@ -339,6 +340,9 @@ export function eraseOpAccount(userId: string): {
339
340
  // TODO.identity-features/08: the developer tokens die with the account
340
341
  // (the hashed rows go — a tombstone's tokens never exchange again).
341
342
  const personalAccessTokens = db.prepare('DELETE FROM personal_access_tokens WHERE user_id = ?').run(userId).changes
343
+ // TODO.identity-features/12: the remembered consent grants die with the
344
+ // account (a tombstone never skips a consent page again).
345
+ const consentGrants = db.prepare('DELETE FROM oidc_consent_grants WHERE user_id = ?').run(userId).changes
342
346
  db.prepare(
343
347
  `UPDATE users SET
344
348
  email = ?, name = 'Deleted account', provider = 'erased',
@@ -346,7 +350,7 @@ export function eraseOpAccount(userId: string): {
346
350
  avatar_url = NULL, email_verified_at = NULL, active = 0
347
351
  WHERE id = ?`,
348
352
  ).run(`deleted-${userId}@erased.invalid`, userId)
349
- return { ...revoked, links, clientRoles, memberships, tokens, factors, personalAccessTokens }
353
+ return { ...revoked, links, clientRoles, memberships, tokens, factors, personalAccessTokens, consentGrants }
350
354
  }
351
355
 
352
356
  /** The last OP-side sign-in per account, FROM THE AUDIT CHAIN: the
@@ -825,3 +825,27 @@ CREATE TABLE IF NOT EXISTS personal_access_tokens (
825
825
  UNIQUE (token_hash)
826
826
  );
827
827
  CREATE INDEX IF NOT EXISTS idx_personal_access_tokens_user ON personal_access_tokens (user_id);
828
+
829
+ -- ═══════════════════════════════════════════════════════════════════
830
+ -- The remembered consent grants (TODO.identity-features/12): the OP
831
+ -- remembers the account holder's "Allow" per (user, client, scope set) —
832
+ -- a repeat authorization the grant COVERS skips the consent page (unless
833
+ -- the request carries prompt=consent). scope is the canonical space-joined
834
+ -- set spelling (normalizeOidcScopeSet); the partial unique index keys ONE
835
+ -- LIVE grant per (user_id, client_id, scope) — revocation flips
836
+ -- revoked_at (the row stays for the audit + the history), a revoked
837
+ -- triple's re-allow lands a fresh row, and the account erasure removes
838
+ -- the rows outright. The D1 migration set carries the identical end state
839
+ -- (0021_oidc_consent_grants.sql).
840
+ -- ═══════════════════════════════════════════════════════════════════
841
+ CREATE TABLE IF NOT EXISTS oidc_consent_grants (
842
+ id TEXT PRIMARY KEY,
843
+ user_id TEXT NOT NULL REFERENCES users(id),
844
+ client_id TEXT NOT NULL,
845
+ scope TEXT NOT NULL,
846
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
847
+ revoked_at TEXT
848
+ );
849
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_oidc_consent_grants_live
850
+ ON oidc_consent_grants (user_id, client_id, scope) WHERE revoked_at IS NULL;
851
+ CREATE INDEX IF NOT EXISTS idx_oidc_consent_grants_user ON oidc_consent_grants (user_id);
@@ -172,7 +172,7 @@ import {
172
172
  updateOpAccount,
173
173
  updateUserName,
174
174
  } from './sqlite/op-accounts-store'
175
- import { installStore, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, type EventKeyFilter, type FederationPeer, type IdentityApproval, type IdentityLink, type IdentityProvider, type InstrumentRegistration, type InstrumentRegistrationLifecycle, type InstrumentRegistrationScopeStatus, type NotifyDelivery, type NotifyDeliveryStatus, type NotifyEntityMute, type NotifyInboxState, type NotifyPreferences, type NotifyRule, type OAuthInitialAssignment, type OidcAccessToken, type OidcAuthorization, type OidcClient, type OidcClientLaunch, type OidcCode, type OidcKeyRow, type OpAccountErasure, type OpClientRoleAssignment, type OpLiveSession, type OrgJoinRequest, type OrgMembership, type OrgMembershipState, type OrgRegistryContact, type OrgRegistryOrg, type OrgRegistryState, type PersonalAccessToken, type PlatformEvent, type ServerStore, type SessionView, type SsoSignInState, type UserAdminRow, type AdvanceCounterResult, type MfaPending, type RecoveryCodeState, type TotpSecret, type WebauthnChallenge, type WebauthnCredential } from '../store'
175
+ import { installStore, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, type EventKeyFilter, type FederationPeer, type IdentityApproval, type IdentityLink, type IdentityProvider, type InstrumentRegistration, type InstrumentRegistrationLifecycle, type InstrumentRegistrationScopeStatus, type NotifyDelivery, type NotifyDeliveryStatus, type NotifyEntityMute, type NotifyInboxState, type NotifyPreferences, type NotifyRule, type OAuthInitialAssignment, type OidcAccessToken, type OidcAuthorization, type OidcClient, type OidcClientLaunch, type OidcCode, type OidcConsentGrant, type OidcKeyRow, type OpAccountErasure, type OpClientRoleAssignment, type OpLiveSession, type OrgJoinRequest, type OrgMembership, type OrgMembershipState, type OrgRegistryContact, type OrgRegistryOrg, type OrgRegistryState, type PersonalAccessToken, type PlatformEvent, type ServerStore, type SessionView, type SsoSignInState, type UserAdminRow, type AdvanceCounterResult, type MfaPending, type RecoveryCodeState, type TotpSecret, type WebauthnChallenge, type WebauthnCredential } from '../store'
176
176
  import {
177
177
  advanceWebauthnCounter,
178
178
  consumeMfaPending,
@@ -205,6 +205,12 @@ import {
205
205
  revokePersonalAccessToken,
206
206
  stampPersonalAccessTokenUse,
207
207
  } from './sqlite/pat-store'
208
+ import {
209
+ getConsentGrant,
210
+ listConsentGrants,
211
+ recordConsentGrant,
212
+ revokeConsentGrant,
213
+ } from './sqlite/consent-grants-store'
208
214
 
209
215
 
210
216
  /** The workflow tables the reset wipe covers (the D1 store's
@@ -652,6 +658,20 @@ export function createSqliteServerStore(): ServerStore {
652
658
  retireOidcKey(kid)
653
659
  },
654
660
 
661
+ // ── the remembered consent grants (TODO.identity-features/12) ──
662
+ async getConsentGrant(userId: string, clientId: string, scope: string): Promise<OidcConsentGrant | null> {
663
+ return getConsentGrant(userId, clientId, scope)
664
+ },
665
+ async recordConsentGrant(input: { userId: string; clientId: string; scope: string }): Promise<OidcConsentGrant> {
666
+ return recordConsentGrant(input)
667
+ },
668
+ async listConsentGrants(userId: string): Promise<OidcConsentGrant[]> {
669
+ return listConsentGrants(userId)
670
+ },
671
+ async revokeConsentGrant(id: string, userId: string): Promise<boolean> {
672
+ return revokeConsentGrant(id, userId)
673
+ },
674
+
655
675
  // ── the upstream providers (TODO.identity/08) ──
656
676
  async listIdentityProviders(): Promise<IdentityProvider[]> {
657
677
  return listIdentityProviders()
package/src/store.ts CHANGED
@@ -436,6 +436,44 @@ export interface OidcKeyRow {
436
436
  retiredAt: string | null
437
437
  }
438
438
 
439
+ // ── the remembered consent grants (TODO.identity-features/12) ────────
440
+
441
+ /** A remembered consent grant (the oidc_consent_grants row): the account
442
+ * holder's "Allow", remembered per (user, client, scope set) so a repeat
443
+ * authorization the grant COVERS skips the consent page. A LIVE grant
444
+ * carries revoked_at NULL — revocation flips the stamp and the row stays
445
+ * (the audit chain's resolvable record); the account erasure removes the
446
+ * rows outright (the PAT doctrine). scope is the CANONICAL set spelling
447
+ * (normalizeOidcScopeSet) — the partial unique index keys one live row
448
+ * per (user, client, scope) triple. */
449
+ export interface OidcConsentGrant {
450
+ id: string
451
+ userId: string
452
+ clientId: string
453
+ /** The granted scope set, space-joined in the canonical spelling. */
454
+ scope: string
455
+ createdAt: string
456
+ revokedAt: string | null
457
+ }
458
+
459
+ /** The scope set's canonical spelling: split on whitespace, drop empties
460
+ * and duplicates, sort — 'profile openid' and 'openid profile' are the
461
+ * SAME set, so the (user, client, scope) triple's uniqueness holds
462
+ * honestly. */
463
+ export function normalizeOidcScopeSet(scope: string): string {
464
+ return [...new Set(scope.split(/\s+/).filter(Boolean))].sort().join(' ')
465
+ }
466
+
467
+ /** The skip check's coverage math (the authorize endpoint's rule): a live
468
+ * grant covers the request when EVERY requested scope is in the granted
469
+ * set. Both sides normalize first, so a hand-edited row still reads as a
470
+ * set — never trusted as a string match. */
471
+ export function consentGrantCovers(grantScope: string, requestedScope: string): boolean {
472
+ const granted = new Set(normalizeOidcScopeSet(grantScope).split(' ').filter(Boolean))
473
+ const requested = normalizeOidcScopeSet(requestedScope).split(' ').filter(Boolean)
474
+ return requested.length > 0 && requested.every(s => granted.has(s))
475
+ }
476
+
439
477
  // ── the upstream providers (TODO.identity/08) ────────────────────────
440
478
 
441
479
  /** An upstream identity provider the OP links + accepts (a registry
@@ -551,7 +589,8 @@ export interface OpClientRoleAssignment {
551
589
  * rows removed (passkeys, TOTP secrets, recovery codes, and the
552
590
  * account's pending ceremony state). TODO.identity-features/08 adds
553
591
  * `personalAccessTokens`: the developer-token rows (a dead account's
554
- * tokens die with it). */
592
+ * tokens die with it). TODO.identity-features/12 adds `consentGrants`:
593
+ * the remembered consent rows (a dead account's grants die with it). */
555
594
  export interface OpAccountErasure {
556
595
  sessions: number
557
596
  accessTokens: number
@@ -563,6 +602,7 @@ export interface OpAccountErasure {
563
602
  tokens: number
564
603
  factors: number
565
604
  personalAccessTokens: number
605
+ consentGrants: number
566
606
  }
567
607
 
568
608
  // ── the account console (TODO.identity/06) ───────────────────────────
@@ -1468,6 +1508,28 @@ export interface ServerStore {
1468
1508
  upsertOidcKey(input: { kid: string; publicJwk: string }): Promise<void>
1469
1509
  retireOidcKey(kid: string): Promise<void>
1470
1510
 
1511
+ // ── the remembered consent grants (TODO.identity-features/12) ──
1512
+ /** The authorize endpoint's remembered-consent read: the account's
1513
+ * LIVE grant for this client whose scope set COVERS the requested set
1514
+ * (the consentGrantCovers math over the live rows), or null — the
1515
+ * consent page shows. Both scope spellings normalize before the math. */
1516
+ getConsentGrant(userId: string, clientId: string, scope: string): Promise<OidcConsentGrant | null>
1517
+ /** The consent decision's remember (the allow): the upsert per
1518
+ * (user, client, scope) — a live triple's row refreshes its stamp (the
1519
+ * re-affirmed consent); a REVOKED triple's re-allow lands a FRESH live
1520
+ * row (the partial unique index keeps the revoked rows out of the
1521
+ * collision, so the history survives). The scope cell stores the
1522
+ * canonical spelling (normalizeOidcScopeSet). Answers the live row. */
1523
+ recordConsentGrant(input: { userId: string; clientId: string; scope: string }): Promise<OidcConsentGrant>
1524
+ /** The account console's "apps they can access": the account's LIVE
1525
+ * grants, newest first (the revoked rows never list — the audit chain
1526
+ * carries them). */
1527
+ listConsentGrants(userId: string): Promise<OidcConsentGrant[]>
1528
+ /** The console's "Revoke access": flips revoked_at on the account's OWN
1529
+ * live row — a second revoke or another account's row answers false
1530
+ * (the PAT guard's posture). The row STAYS. */
1531
+ revokeConsentGrant(id: string, userId: string): Promise<boolean>
1532
+
1471
1533
  // ── the upstream providers (TODO.identity/08) ──
1472
1534
  /** The upstream registry (admin-managed; OP_UPSTREAM_SEED bootstraps).
1473
1535
  * Secrets are NEVER in these rows — clientSecretRef is an env name. */