@oimlsmart/platform-server 0.1.5 → 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,51 @@
1
+ -- Migration 0020 — the personal access tokens (TODO.identity-features/08,
2
+ -- the GitHub fine-grained pattern mapped to the estate): an ACCOUNT-minted
3
+ -- developer credential that NEVER rides a request directly — it exchanges
4
+ -- at the OP's token endpoint (the RFC 8693 grant, subject_token_type
5
+ -- urn:oimlsmart:params:oauth:token-type:pat) for a short-lived OP JWT, so
6
+ -- every relying party keeps validating the one token shape.
7
+ --
8
+ -- The store doctrines (the recovery codes' precedent, migration 0012):
9
+ -- - the plaintext shows ONCE at mint; the row holds only the SHA-256 of
10
+ -- the presented token (256 bits of random — an unsalted hash resists
11
+ -- the offline attack), and token_hash IS the exchange's lookup key
12
+ -- (UNIQUE doubles as its index);
13
+ -- - token_prefix is the display fragment ('ospt_' + the leading
14
+ -- characters — the console's row label, GitHub's list convention),
15
+ -- never enough to authenticate;
16
+ -- - expiration is MANDATORY (expires_at NOT NULL — the fine-grained
17
+ -- lesson: no permanent tokens);
18
+ -- - the audit chain rides the row conservatively: last_used_at +
19
+ -- last_exchange_audit_at carry the exchange path's THROTTLED
20
+ -- heartbeat (never a write per exchange), expiry_notified_at the
21
+ -- expiry-soon mailer's one-shot mark;
22
+ -- - revocation flips revoked_at/revoked_by (the row stays — the audit
23
+ -- + the org inventory carry the history); the account erasure removes
24
+ -- the rows outright (a dead account's tokens die with it).
25
+ -- schema.sql carries the same end state for fresh databases —
26
+ -- test/migrations.test.ts pins the UNION of every migration to
27
+ -- schema.sql's CREATE set.
28
+
29
+ CREATE TABLE IF NOT EXISTS personal_access_tokens (
30
+ id TEXT PRIMARY KEY,
31
+ user_id TEXT NOT NULL REFERENCES users(id),
32
+ name TEXT NOT NULL,
33
+ token_hash TEXT NOT NULL,
34
+ token_prefix TEXT NOT NULL,
35
+ -- The granted scope set (JSON array of '<service>:<action-class>' — the
36
+ -- kernel's PAT grammar; narrowing-only against the holder's standing).
37
+ scopes TEXT NOT NULL DEFAULT '[]',
38
+ -- The org context the token was minted under (the console session's
39
+ -- active org — the token acts within the account's active-org
40
+ -- visibility, never wider). NULL = the account's primary context.
41
+ org_context TEXT,
42
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
43
+ expires_at TEXT NOT NULL,
44
+ last_used_at TEXT,
45
+ last_exchange_audit_at TEXT,
46
+ expiry_notified_at TEXT,
47
+ revoked_at TEXT,
48
+ revoked_by TEXT,
49
+ UNIQUE (token_hash)
50
+ );
51
+ CREATE INDEX IF NOT EXISTS idx_personal_access_tokens_user ON personal_access_tokens (user_id);
@@ -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.5",
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,
@@ -62,6 +65,7 @@ import {
62
65
  type InstrumentRegistration,
63
66
  type InstrumentRegistrationLifecycle,
64
67
  type InstrumentRegistrationScopeStatus,
68
+ type PersonalAccessToken,
65
69
  type PlatformEvent,
66
70
  resolveOrgContext,
67
71
  parseOrgMemberCone,
@@ -442,6 +446,70 @@ export class D1ServerStore implements ServerStore {
442
446
  return this.oidcColumnsEnsured
443
447
  }
444
448
 
449
+ // TODO.identity-features/08 (the personal access tokens): the
450
+ // personal_access_tokens table arrives with migration 0020 — a dev D1
451
+ // migrated from before it lacks the table, so the PAT methods ensure
452
+ // it defensively (the ensureOrgRegistrySupport posture, memoized per
453
+ // store).
454
+ private personalAccessTokenSupportEnsured: Promise<void> | null = null
455
+
456
+ private ensurePersonalAccessTokenSupport(): Promise<void> {
457
+ if (!this.personalAccessTokenSupportEnsured) {
458
+ this.personalAccessTokenSupportEnsured = (async () => {
459
+ await this.db.prepare(
460
+ `CREATE TABLE IF NOT EXISTS personal_access_tokens (
461
+ id TEXT PRIMARY KEY,
462
+ user_id TEXT NOT NULL REFERENCES users(id),
463
+ name TEXT NOT NULL,
464
+ token_hash TEXT NOT NULL,
465
+ token_prefix TEXT NOT NULL,
466
+ scopes TEXT NOT NULL DEFAULT '[]',
467
+ org_context TEXT,
468
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
469
+ expires_at TEXT NOT NULL,
470
+ last_used_at TEXT,
471
+ last_exchange_audit_at TEXT,
472
+ expiry_notified_at TEXT,
473
+ revoked_at TEXT,
474
+ revoked_by TEXT,
475
+ UNIQUE (token_hash)
476
+ )`,
477
+ ).run()
478
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_personal_access_tokens_user ON personal_access_tokens (user_id)').run()
479
+ })()
480
+ }
481
+ return this.personalAccessTokenSupportEnsured
482
+ }
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
+
445
513
  // ── users / sessions ─────────────────────────────────────────────
446
514
 
447
515
  async seedDemoAccounts(): Promise<void> {
@@ -1089,6 +1157,78 @@ export class D1ServerStore implements ServerStore {
1089
1157
  ).run()
1090
1158
  }
1091
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
+
1092
1232
  // ── the upstream providers (TODO.identity/08) ─────────────────────
1093
1233
  // The provider + link rows port directly (D1 is SQLite).
1094
1234
 
@@ -1471,6 +1611,15 @@ export class D1ServerStore implements ServerStore {
1471
1611
  const recovery = await this.stmt('DELETE FROM recovery_codes WHERE user_id = ?', userId).run()
1472
1612
  const challenges = await this.stmt('DELETE FROM webauthn_challenges WHERE user_id = ?', userId).run()
1473
1613
  const mfa = await this.stmt('DELETE FROM mfa_pending WHERE user_id = ?', userId).run()
1614
+ // TODO.identity-features/08: the developer tokens die with the
1615
+ // account (the hashed rows go — a tombstone's tokens never exchange
1616
+ // again).
1617
+ await this.ensurePersonalAccessTokenSupport()
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()
1474
1623
  await this.stmt(
1475
1624
  `UPDATE users SET
1476
1625
  email = ?, name = 'Deleted account', provider = 'erased',
@@ -1487,6 +1636,8 @@ export class D1ServerStore implements ServerStore {
1487
1636
  tokens: (passwords.meta.changes ?? 0) + (enrollments.meta.changes ?? 0) + (emailChanges.meta.changes ?? 0),
1488
1637
  factors: (passkeys.meta.changes ?? 0) + (totp.meta.changes ?? 0) + (recovery.meta.changes ?? 0)
1489
1638
  + (challenges.meta.changes ?? 0) + (mfa.meta.changes ?? 0),
1639
+ personalAccessTokens: personalAccessTokens.meta.changes ?? 0,
1640
+ consentGrants: consentGrants.meta.changes ?? 0,
1490
1641
  }
1491
1642
  }
1492
1643
 
@@ -1843,6 +1994,92 @@ export class D1ServerStore implements ServerStore {
1843
1994
  return row ? D1ServerStore.toMfaPending(row) : null
1844
1995
  }
1845
1996
 
1997
+ // ── the personal access tokens (TODO.identity-features/08) ─────────
1998
+
1999
+ async createPersonalAccessToken(input: {
2000
+ id: string
2001
+ userId: string
2002
+ name: string
2003
+ tokenHash: string
2004
+ tokenPrefix: string
2005
+ scopes: string[]
2006
+ orgContext: string | null
2007
+ expiresAt: string
2008
+ }): Promise<PersonalAccessToken> {
2009
+ await this.ensurePersonalAccessTokenSupport()
2010
+ await this.stmt(
2011
+ `INSERT INTO personal_access_tokens
2012
+ (id, user_id, name, token_hash, token_prefix, scopes, org_context, expires_at)
2013
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
2014
+ input.id, input.userId, input.name, input.tokenHash, input.tokenPrefix,
2015
+ JSON.stringify(input.scopes), input.orgContext, input.expiresAt,
2016
+ ).run()
2017
+ return (await this.getPersonalAccessToken(input.id))!
2018
+ }
2019
+
2020
+ async listPersonalAccessTokens(userId: string): Promise<PersonalAccessToken[]> {
2021
+ await this.ensurePersonalAccessTokenSupport()
2022
+ // created_at is second-resolution (datetime('now')) — the rowid
2023
+ // breaks the tie so the newest mint leads even within one second.
2024
+ const res = await this.stmt(
2025
+ 'SELECT * FROM personal_access_tokens WHERE user_id = ? ORDER BY created_at DESC, rowid DESC', userId,
2026
+ ).all<Record<string, unknown>>()
2027
+ return res.results.map(D1ServerStore.toPersonalAccessToken)
2028
+ }
2029
+
2030
+ async listOrgPersonalAccessTokens(orgId: string): Promise<PersonalAccessToken[]> {
2031
+ await this.ensurePersonalAccessTokenSupport()
2032
+ // The org inventory: every token whose holder carries a membership
2033
+ // row for the org (ANY state — a disabled member's live token is
2034
+ // exactly what the oversight surface hunts). org_memberships arrived
2035
+ // with 0011, long before the PAT table — the join needs no ensure of
2036
+ // its own beyond the membership support's.
2037
+ await this.ensureMembershipSupport()
2038
+ const res = await this.stmt(
2039
+ `SELECT p.* FROM personal_access_tokens p
2040
+ JOIN org_memberships m ON m.user_id = p.user_id
2041
+ WHERE m.org_id = ?
2042
+ ORDER BY p.created_at DESC, p.id`,
2043
+ orgId,
2044
+ ).all<Record<string, unknown>>()
2045
+ return res.results.map(D1ServerStore.toPersonalAccessToken)
2046
+ }
2047
+
2048
+ async getPersonalAccessToken(id: string): Promise<PersonalAccessToken | null> {
2049
+ await this.ensurePersonalAccessTokenSupport()
2050
+ const row = await this.stmt('SELECT * FROM personal_access_tokens WHERE id = ?', id).first<Record<string, unknown>>()
2051
+ return row ? D1ServerStore.toPersonalAccessToken(row) : null
2052
+ }
2053
+
2054
+ async findPersonalAccessTokenByHash(tokenHash: string): Promise<PersonalAccessToken | null> {
2055
+ await this.ensurePersonalAccessTokenSupport()
2056
+ const row = await this.stmt('SELECT * FROM personal_access_tokens WHERE token_hash = ?', tokenHash).first<Record<string, unknown>>()
2057
+ return row ? D1ServerStore.toPersonalAccessToken(row) : null
2058
+ }
2059
+
2060
+ async revokePersonalAccessToken(id: string, userId: string, revokedBy: string): Promise<boolean> {
2061
+ await this.ensurePersonalAccessTokenSupport()
2062
+ const res = await this.stmt(
2063
+ "UPDATE personal_access_tokens SET revoked_at = datetime('now'), revoked_by = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
2064
+ revokedBy, id, userId,
2065
+ ).run()
2066
+ return (res.meta.changes ?? 0) > 0
2067
+ }
2068
+
2069
+ async stampPersonalAccessTokenUse(
2070
+ id: string,
2071
+ stamps: { usedAt: string; auditAt?: string | null; expiryNotifiedAt?: string | null },
2072
+ ): Promise<void> {
2073
+ await this.ensurePersonalAccessTokenSupport()
2074
+ await this.stmt('UPDATE personal_access_tokens SET last_used_at = ? WHERE id = ?', stamps.usedAt, id).run()
2075
+ if (stamps.auditAt) {
2076
+ await this.stmt('UPDATE personal_access_tokens SET last_exchange_audit_at = ? WHERE id = ?', stamps.auditAt, id).run()
2077
+ }
2078
+ if (stamps.expiryNotifiedAt) {
2079
+ await this.stmt('UPDATE personal_access_tokens SET expiry_notified_at = ? WHERE id = ?', stamps.expiryNotifiedAt, id).run()
2080
+ }
2081
+ }
2082
+
1846
2083
  // ── organization administration (TODO.identity/10) ────────────────
1847
2084
 
1848
2085
  /** The store's time columns arrive in two shapes (datetime('now')'s
@@ -1910,6 +2147,28 @@ export class D1ServerStore implements ServerStore {
1910
2147
  }
1911
2148
  }
1912
2149
 
2150
+ /** The personal_access_tokens row → the seam's shape (TODO.identity-
2151
+ * features/08). The scopes cell parses defensively — a hand-edited
2152
+ * row's malformed JSON reads as the empty set, never trusted. */
2153
+ private static toPersonalAccessToken(row: Record<string, unknown>): PersonalAccessToken {
2154
+ return {
2155
+ id: row.id as string,
2156
+ userId: row.user_id as string,
2157
+ name: row.name as string,
2158
+ tokenHash: row.token_hash as string,
2159
+ tokenPrefix: row.token_prefix as string,
2160
+ scopes: parseRoles((row.scopes as string | null) ?? null) ?? [],
2161
+ orgContext: (row.org_context as string | null) ?? null,
2162
+ createdAt: D1ServerStore.storeTimeToIso(row.created_at as string)!,
2163
+ expiresAt: D1ServerStore.storeTimeToIso(row.expires_at as string)!,
2164
+ lastUsedAt: D1ServerStore.storeTimeToIso((row.last_used_at as string | null) ?? null),
2165
+ lastExchangeAuditAt: D1ServerStore.storeTimeToIso((row.last_exchange_audit_at as string | null) ?? null),
2166
+ expiryNotifiedAt: D1ServerStore.storeTimeToIso((row.expiry_notified_at as string | null) ?? null),
2167
+ revokedAt: D1ServerStore.storeTimeToIso((row.revoked_at as string | null) ?? null),
2168
+ revokedBy: (row.revoked_by as string | null) ?? null,
2169
+ }
2170
+ }
2171
+
1913
2172
  private static toOrgJoinRequest(row: Record<string, unknown>): OrgJoinRequest {
1914
2173
  return {
1915
2174
  id: row.id as string,
@@ -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
+ }
@@ -312,6 +312,8 @@ export function eraseOpAccount(userId: string): {
312
312
  memberships: number
313
313
  tokens: number
314
314
  factors: number
315
+ personalAccessTokens: number
316
+ consentGrants: number
315
317
  } | null {
316
318
  const db = getDb()
317
319
  const row = db.prepare('SELECT 1 AS ok FROM users WHERE id = ?').get(userId)
@@ -335,6 +337,12 @@ export function eraseOpAccount(userId: string): {
335
337
  db.prepare('DELETE FROM recovery_codes WHERE user_id = ?').run(userId).changes +
336
338
  db.prepare('DELETE FROM webauthn_challenges WHERE user_id = ?').run(userId).changes +
337
339
  db.prepare('DELETE FROM mfa_pending WHERE user_id = ?').run(userId).changes
340
+ // TODO.identity-features/08: the developer tokens die with the account
341
+ // (the hashed rows go — a tombstone's tokens never exchange again).
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
338
346
  db.prepare(
339
347
  `UPDATE users SET
340
348
  email = ?, name = 'Deleted account', provider = 'erased',
@@ -342,7 +350,7 @@ export function eraseOpAccount(userId: string): {
342
350
  avatar_url = NULL, email_verified_at = NULL, active = 0
343
351
  WHERE id = ?`,
344
352
  ).run(`deleted-${userId}@erased.invalid`, userId)
345
- return { ...revoked, links, clientRoles, memberships, tokens, factors }
353
+ return { ...revoked, links, clientRoles, memberships, tokens, factors, personalAccessTokens, consentGrants }
346
354
  }
347
355
 
348
356
  /** The last OP-side sign-in per account, FROM THE AUDIT CHAIN: the
@@ -0,0 +1,127 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // The personal access tokens' SQLite half (TODO.identity-features/08) —
3
+ // the sync implementations behind the ServerStore PAT methods
4
+ // (sqlite-server-store.ts delegates here one-for-one, the
5
+ // factors-store.ts pattern). The D1 store implements the same surface
6
+ // in d1.ts.
7
+ //
8
+ // The doctrines carried:
9
+ // - the plaintext NEVER crosses the seam: the row holds the SHA-256
10
+ // (token_hash, the exchange's UNIQUE lookup key) + the display
11
+ // prefix, and no read projects the hash onto a list surface;
12
+ // - the revoke is a GUARDED update (the live row, the owner's) — a
13
+ // replay or a foreign owner answers false, the row stays for the
14
+ // audit + the org inventory;
15
+ // - the erasure (op-accounts-store.ts's eraseOpAccount) removes the
16
+ // rows outright — a dead account's tokens die with it.
17
+ //
18
+ // NODE-ONLY: better-sqlite3 through ./store's getDb. The Worker bundle
19
+ // never sees this module.
20
+ // ═══════════════════════════════════════════════════════════════════
21
+
22
+ import { getDb } from './store'
23
+ import { storeTimeToIso } from './factors-store'
24
+ import type { PersonalAccessToken } from '../../store'
25
+
26
+ /** The row → the seam's shape. The scopes cell parses defensively (a
27
+ * hand-edited row's malformed JSON reads as the empty set — never
28
+ * trusted, never breaking the read). */
29
+ function toPersonalAccessToken(row: Record<string, unknown>): PersonalAccessToken {
30
+ let scopes: string[] = []
31
+ try {
32
+ const parsed = JSON.parse((row.scopes as string | null) ?? '[]') as unknown
33
+ if (Array.isArray(parsed)) scopes = parsed.filter((s): s is string => typeof s === 'string')
34
+ } catch { /* a malformed scopes cell reads as none — the token exchanges nothing */ }
35
+ return {
36
+ id: row.id as string,
37
+ userId: row.user_id as string,
38
+ name: row.name as string,
39
+ tokenHash: row.token_hash as string,
40
+ tokenPrefix: row.token_prefix as string,
41
+ scopes,
42
+ orgContext: (row.org_context as string | null) ?? null,
43
+ createdAt: storeTimeToIso(row.created_at as string)!,
44
+ expiresAt: storeTimeToIso(row.expires_at as string)!,
45
+ lastUsedAt: storeTimeToIso((row.last_used_at as string | null) ?? null),
46
+ lastExchangeAuditAt: storeTimeToIso((row.last_exchange_audit_at as string | null) ?? null),
47
+ expiryNotifiedAt: storeTimeToIso((row.expiry_notified_at as string | null) ?? null),
48
+ revokedAt: storeTimeToIso((row.revoked_at as string | null) ?? null),
49
+ revokedBy: (row.revoked_by as string | null) ?? null,
50
+ }
51
+ }
52
+
53
+ export function createPersonalAccessToken(input: {
54
+ id: string
55
+ userId: string
56
+ name: string
57
+ tokenHash: string
58
+ tokenPrefix: string
59
+ scopes: string[]
60
+ orgContext: string | null
61
+ expiresAt: string
62
+ }): PersonalAccessToken {
63
+ getDb().prepare(
64
+ `INSERT INTO personal_access_tokens
65
+ (id, user_id, name, token_hash, token_prefix, scopes, org_context, expires_at)
66
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
67
+ ).run(
68
+ input.id, input.userId, input.name, input.tokenHash, input.tokenPrefix,
69
+ JSON.stringify(input.scopes), input.orgContext, input.expiresAt,
70
+ )
71
+ return getPersonalAccessToken(input.id)!
72
+ }
73
+
74
+ export function listPersonalAccessTokens(userId: string): PersonalAccessToken[] {
75
+ // created_at is second-resolution (datetime('now')) — the rowid breaks
76
+ // the tie so the newest mint leads even within one second.
77
+ const rows = getDb().prepare(
78
+ 'SELECT * FROM personal_access_tokens WHERE user_id = ? ORDER BY created_at DESC, rowid DESC',
79
+ ).all(userId) as Array<Record<string, unknown>>
80
+ return rows.map(toPersonalAccessToken)
81
+ }
82
+
83
+ /** The org inventory: every token whose holder carries a membership row
84
+ * for the org (ANY state — the oversight surface hunts the disabled
85
+ * member's live token too), newest first. */
86
+ export function listOrgPersonalAccessTokens(orgId: string): PersonalAccessToken[] {
87
+ const rows = getDb().prepare(
88
+ `SELECT p.* FROM personal_access_tokens p
89
+ JOIN org_memberships m ON m.user_id = p.user_id
90
+ WHERE m.org_id = ?
91
+ ORDER BY p.created_at DESC, p.id`,
92
+ ).all(orgId) as Array<Record<string, unknown>>
93
+ return rows.map(toPersonalAccessToken)
94
+ }
95
+
96
+ export function getPersonalAccessToken(id: string): PersonalAccessToken | null {
97
+ const row = getDb().prepare('SELECT * FROM personal_access_tokens WHERE id = ?').get(id) as Record<string, unknown> | undefined
98
+ return row ? toPersonalAccessToken(row) : null
99
+ }
100
+
101
+ export function findPersonalAccessTokenByHash(tokenHash: string): PersonalAccessToken | null {
102
+ const row = getDb().prepare('SELECT * FROM personal_access_tokens WHERE token_hash = ?').get(tokenHash) as Record<string, unknown> | undefined
103
+ return row ? toPersonalAccessToken(row) : null
104
+ }
105
+
106
+ /** The guarded revoke: the owner's LIVE row flips, once. */
107
+ export function revokePersonalAccessToken(id: string, userId: string, revokedBy: string): boolean {
108
+ return getDb().prepare(
109
+ "UPDATE personal_access_tokens SET revoked_at = datetime('now'), revoked_by = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
110
+ ).run(revokedBy, id, userId).changes > 0
111
+ }
112
+
113
+ /** The exchange path's stamp (the throttled heartbeat + the expiry-soon
114
+ * mailer's one-shot mark — the route decides, the store writes). */
115
+ export function stampPersonalAccessTokenUse(
116
+ id: string,
117
+ stamps: { usedAt: string; auditAt?: string | null; expiryNotifiedAt?: string | null },
118
+ ): void {
119
+ const db = getDb()
120
+ db.prepare('UPDATE personal_access_tokens SET last_used_at = ? WHERE id = ?').run(stamps.usedAt, id)
121
+ if (stamps.auditAt) {
122
+ db.prepare('UPDATE personal_access_tokens SET last_exchange_audit_at = ? WHERE id = ?').run(stamps.auditAt, id)
123
+ }
124
+ if (stamps.expiryNotifiedAt) {
125
+ db.prepare('UPDATE personal_access_tokens SET expiry_notified_at = ? WHERE id = ?').run(stamps.expiryNotifiedAt, id)
126
+ }
127
+ }
@@ -787,3 +787,65 @@ CREATE TABLE IF NOT EXISTS instrument_registrations (
787
787
  CREATE INDEX IF NOT EXISTS idx_instrument_registrations_certificate ON instrument_registrations (certificate_id);
788
788
  CREATE INDEX IF NOT EXISTS idx_instrument_registrations_holder ON instrument_registrations (holder_org_id);
789
789
  CREATE INDEX IF NOT EXISTS idx_instrument_registrations_lifecycle ON instrument_registrations (lifecycle);
790
+
791
+ -- ═══════════════════════════════════════════════════════════════════
792
+ -- TODO.identity-features/08 — the personal access tokens (the developer
793
+ -- surface, the GitHub fine-grained pattern): an ACCOUNT-minted
794
+ -- credential that NEVER rides a request directly — it exchanges at the
795
+ -- OP's token endpoint (the RFC 8693 grant) for a short-lived OP JWT.
796
+ -- The plaintext shows ONCE at mint; the row holds only the SHA-256
797
+ -- (token_hash, the exchange's UNIQUE lookup key) + the display prefix.
798
+ -- Expiration is MANDATORY (expires_at NOT NULL — no permanent tokens).
799
+ -- scopes is the pinned JSON set ('<service>:<action-class>' — the
800
+ -- store.ts grammar; narrowing-only against the holder's standing);
801
+ -- org_context pins the mint's active-org context (NULL = the primary).
802
+ -- last_used_at + last_exchange_audit_at carry the exchange path's
803
+ -- THROTTLED heartbeat (never a write per exchange); expiry_notified_at
804
+ -- is the expiry-soon mailer's one-shot mark. Revocation flips
805
+ -- revoked_at/revoked_by and the row STAYS (the audit + the org
806
+ -- inventory carry the history); the account erasure removes the rows.
807
+ -- The D1 migration set carries the identical end state
808
+ -- (0020_personal_access_tokens.sql).
809
+ -- ═══════════════════════════════════════════════════════════════════
810
+ CREATE TABLE IF NOT EXISTS personal_access_tokens (
811
+ id TEXT PRIMARY KEY,
812
+ user_id TEXT NOT NULL REFERENCES users(id),
813
+ name TEXT NOT NULL,
814
+ token_hash TEXT NOT NULL,
815
+ token_prefix TEXT NOT NULL,
816
+ scopes TEXT NOT NULL DEFAULT '[]',
817
+ org_context TEXT,
818
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
819
+ expires_at TEXT NOT NULL,
820
+ last_used_at TEXT,
821
+ last_exchange_audit_at TEXT,
822
+ expiry_notified_at TEXT,
823
+ revoked_at TEXT,
824
+ revoked_by TEXT,
825
+ UNIQUE (token_hash)
826
+ );
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 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,
@@ -196,6 +196,21 @@ import {
196
196
  replaceRecoveryCodes,
197
197
  recoveryCodeState,
198
198
  } from './sqlite/factors-store'
199
+ import {
200
+ createPersonalAccessToken,
201
+ findPersonalAccessTokenByHash,
202
+ getPersonalAccessToken,
203
+ listOrgPersonalAccessTokens,
204
+ listPersonalAccessTokens,
205
+ revokePersonalAccessToken,
206
+ stampPersonalAccessTokenUse,
207
+ } from './sqlite/pat-store'
208
+ import {
209
+ getConsentGrant,
210
+ listConsentGrants,
211
+ recordConsentGrant,
212
+ revokeConsentGrant,
213
+ } from './sqlite/consent-grants-store'
199
214
 
200
215
 
201
216
  /** The workflow tables the reset wipe covers (the D1 store's
@@ -643,6 +658,20 @@ export function createSqliteServerStore(): ServerStore {
643
658
  retireOidcKey(kid)
644
659
  },
645
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
+
646
675
  // ── the upstream providers (TODO.identity/08) ──
647
676
  async listIdentityProviders(): Promise<IdentityProvider[]> {
648
677
  return listIdentityProviders()
@@ -845,6 +874,41 @@ export function createSqliteServerStore(): ServerStore {
845
874
  return recordMfaPendingFailure(token)
846
875
  },
847
876
 
877
+ // ── the personal access tokens (TODO.identity-features/08) ──
878
+ async createPersonalAccessToken(input: {
879
+ id: string
880
+ userId: string
881
+ name: string
882
+ tokenHash: string
883
+ tokenPrefix: string
884
+ scopes: string[]
885
+ orgContext: string | null
886
+ expiresAt: string
887
+ }): Promise<PersonalAccessToken> {
888
+ return createPersonalAccessToken(input)
889
+ },
890
+ async listPersonalAccessTokens(userId: string): Promise<PersonalAccessToken[]> {
891
+ return listPersonalAccessTokens(userId)
892
+ },
893
+ async listOrgPersonalAccessTokens(orgId: string): Promise<PersonalAccessToken[]> {
894
+ return listOrgPersonalAccessTokens(orgId)
895
+ },
896
+ async getPersonalAccessToken(id: string): Promise<PersonalAccessToken | null> {
897
+ return getPersonalAccessToken(id)
898
+ },
899
+ async findPersonalAccessTokenByHash(tokenHash: string): Promise<PersonalAccessToken | null> {
900
+ return findPersonalAccessTokenByHash(tokenHash)
901
+ },
902
+ async revokePersonalAccessToken(id: string, userId: string, revokedBy: string): Promise<boolean> {
903
+ return revokePersonalAccessToken(id, userId, revokedBy)
904
+ },
905
+ async stampPersonalAccessTokenUse(
906
+ id: string,
907
+ stamps: { usedAt: string; auditAt?: string | null; expiryNotifiedAt?: string | null },
908
+ ): Promise<void> {
909
+ stampPersonalAccessTokenUse(id, stamps)
910
+ },
911
+
848
912
  // ── the central user registry (TODO.identity/03) ──
849
913
  async listOpClientRoles(userId: string): Promise<OpClientRoleAssignment[]> {
850
914
  return listOpClientRoles(userId)
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
@@ -549,7 +587,10 @@ export interface OpClientRoleAssignment {
549
587
  * credential/token rows (passwords, enrollment tokens, email-change
550
588
  * tokens). TODO.identity-sso/02+03 adds `factors`: the factor-registry
551
589
  * rows removed (passkeys, TOTP secrets, recovery codes, and the
552
- * account's pending ceremony state). */
590
+ * account's pending ceremony state). TODO.identity-features/08 adds
591
+ * `personalAccessTokens`: the developer-token rows (a dead account's
592
+ * tokens die with it). TODO.identity-features/12 adds `consentGrants`:
593
+ * the remembered consent rows (a dead account's grants die with it). */
553
594
  export interface OpAccountErasure {
554
595
  sessions: number
555
596
  accessTokens: number
@@ -560,6 +601,8 @@ export interface OpAccountErasure {
560
601
  memberships: number
561
602
  tokens: number
562
603
  factors: number
604
+ personalAccessTokens: number
605
+ consentGrants: number
563
606
  }
564
607
 
565
608
  // ── the account console (TODO.identity/06) ───────────────────────────
@@ -676,6 +719,121 @@ export interface MfaPending {
676
719
  consumedAt: string | null
677
720
  }
678
721
 
722
+ // ── the personal access tokens (TODO.identity-features/08) ───────────
723
+ // The developer surface: an ACCOUNT-minted credential for programmatic
724
+ // access (the lab CLI, scripts, the agent pipelines). The GitHub
725
+ // fine-grained pattern mapped to the estate:
726
+ //
727
+ // - the PAT NEVER rides a request directly — it exchanges at the OP's
728
+ // token endpoint (the RFC 8693 grant, subject_token_type
729
+ // urn:oimlsmart:params:oauth:token-type:pat) for a short-lived OP
730
+ // JWT, so every relying party keeps validating the ONE token shape;
731
+ // - the plaintext shows ONCE at mint; the row holds only the SHA-256
732
+ // (256 bits of random — the recovery codes' unsalted-hash posture),
733
+ // and token_hash IS the exchange's lookup key;
734
+ // - expiration is MANDATORY (90 days default, 1 year the ceiling);
735
+ // - a token only ever NARROWS the account: its scopes are a subset of
736
+ // the holder's standing, enforced at mint AND re-judged at exchange;
737
+ // - never an ORG credential: org-level automation speaks the org's
738
+ // registered clients (the machine cone), never a person's token.
739
+
740
+ /** The PAT wire prefix (the GitHub `github_pat_` convention): the
741
+ * minted token is `${PAT_TOKEN_PREFIX}${43 base64url chars}` (32 random
742
+ * bytes). The prefix lets the exchange path recognize the cone and lets
743
+ * leak scanners catch a committed token. */
744
+ export const PAT_TOKEN_PREFIX = 'ospt_'
745
+
746
+ /** The action class's ordinality: admin ⊃ write ⊃ read. The RP's at-use
747
+ * check (patScopeCovers) reads it — a write token never mints admin
748
+ * acts, an admin token covers the read. */
749
+ export const PAT_ACTION_CLASSES = ['read', 'write', 'admin'] as const
750
+ export type PatActionClass = (typeof PAT_ACTION_CLASSES)[number]
751
+
752
+ /** One parsed scope: the service (a registered application-class OIDC
753
+ * client id — the estate's service registry IS the OP's client
754
+ * registry) × the action class. */
755
+ export interface PatScope {
756
+ service: string
757
+ action: PatActionClass
758
+ }
759
+
760
+ /** Parse one scope spelling ('<service>:<action-class>'). Total, never
761
+ * throws: a malformed spelling answers null (the mint refuses it, a
762
+ * stored row's malformed cell is skipped on read — never trusted). */
763
+ export function parsePatScope(raw: unknown): PatScope | null {
764
+ if (typeof raw !== 'string') return null
765
+ const m = /^([a-z0-9][a-z0-9._-]{0,127}):(read|write|admin)$/.exec(raw.trim())
766
+ if (!m) return null
767
+ return { service: m[1]!, action: m[2] as PatActionClass }
768
+ }
769
+
770
+ /** The canonical spelling (the store column's cell, the JWT's scope
771
+ * claim's word). */
772
+ export function encodePatScope(scope: PatScope): string {
773
+ return `${scope.service}:${scope.action}`
774
+ }
775
+
776
+ /** Normalize a scope set: parse every cell (a malformed cell refuses the
777
+ * WHOLE set at mint — null), drop duplicates, and fold a service's
778
+ * classes to the WIDEST (hub:read + hub:write is hub:write — the
779
+ * ordinal subsumes). The answer sorts for a stable wire/claim shape. */
780
+ export function normalizePatScopes(raw: unknown): PatScope[] | null {
781
+ if (!Array.isArray(raw) || raw.length === 0) return null
782
+ const widest = new Map<string, PatActionClass>()
783
+ for (const cell of raw) {
784
+ const scope = parsePatScope(cell)
785
+ if (!scope) return null
786
+ const held = widest.get(scope.service)
787
+ if (!held || PAT_ACTION_CLASSES.indexOf(scope.action) > PAT_ACTION_CLASSES.indexOf(held)) {
788
+ widest.set(scope.service, scope.action)
789
+ }
790
+ }
791
+ return [...widest.entries()]
792
+ .map(([service, action]) => ({ service, action }))
793
+ .sort((a, b) => a.service.localeCompare(b.service))
794
+ }
795
+
796
+ /** THE NARROWING INVARIANT (the spec's core: scopes ≤ the holder's,
797
+ * enforced at exchange AND at use): every granted scope must be covered
798
+ * by the ceiling set — the account's current standing at mint/exchange,
799
+ * the token's own pinned set when a caller narrows per exchange. */
800
+ export function patScopesWithin(granted: readonly PatScope[], ceiling: readonly PatScope[]): boolean {
801
+ return granted.every(g => patScopeCovers(ceiling, g.service, g.action))
802
+ }
803
+
804
+ /** The at-use check (the RP's bearer gate — the RBAC map's token-scope
805
+ * cone): does the granted set cover this service at this action class?
806
+ * Ordinal: a wider class covers the narrower. */
807
+ export function patScopeCovers(granted: readonly PatScope[], service: string, action: PatActionClass): boolean {
808
+ const need = PAT_ACTION_CLASSES.indexOf(action)
809
+ return granted.some(g => g.service === service && PAT_ACTION_CLASSES.indexOf(g.action) >= need)
810
+ }
811
+
812
+ /** A personal access token's row (the personal_access_tokens table).
813
+ * NEVER the plaintext, never reversible material: tokenHash is the
814
+ * SHA-256 lookup key, tokenPrefix the console's display fragment.
815
+ * orgContext pins the mint's active-org context (null = the account's
816
+ * primary); lastUsedAt / lastExchangeAuditAt carry the exchange path's
817
+ * throttled heartbeat; expiryNotifiedAt the expiry-soon mailer's
818
+ * one-shot mark. */
819
+ export interface PersonalAccessToken {
820
+ id: string
821
+ userId: string
822
+ name: string
823
+ tokenHash: string
824
+ tokenPrefix: string
825
+ /** The pinned scope set (the encoded spellings, normalized at mint). */
826
+ scopes: string[]
827
+ orgContext: string | null
828
+ createdAt: string
829
+ expiresAt: string
830
+ lastUsedAt: string | null
831
+ lastExchangeAuditAt: string | null
832
+ expiryNotifiedAt: string | null
833
+ revokedAt: string | null
834
+ revokedBy: string | null
835
+ }
836
+
679
837
 
680
838
  // ── organization administration (TODO.identity/10) ───────────────────
681
839
 
@@ -1350,6 +1508,28 @@ export interface ServerStore {
1350
1508
  upsertOidcKey(input: { kid: string; publicJwk: string }): Promise<void>
1351
1509
  retireOidcKey(kid: string): Promise<void>
1352
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
+
1353
1533
  // ── the upstream providers (TODO.identity/08) ──
1354
1534
  /** The upstream registry (admin-managed; OP_UPSTREAM_SEED bootstraps).
1355
1535
  * Secrets are NEVER in these rows — clientSecretRef is an env name. */
@@ -1606,6 +1786,46 @@ export interface ServerStore {
1606
1786
  * fresh row (null when the token is gone). */
1607
1787
  recordMfaPendingFailure(token: string): Promise<MfaPending | null>
1608
1788
 
1789
+ // ── the personal access tokens (TODO.identity-features/08) ──
1790
+ /** The mint: one row per token. The plaintext NEVER crosses the seam —
1791
+ * the caller hashes (SHA-256) and the row holds the hash + the
1792
+ * display prefix. expiresAt is mandatory (the route enforces the
1793
+ * 1-year ceiling; the store trusts the route's arithmetic). */
1794
+ createPersonalAccessToken(input: {
1795
+ id: string
1796
+ userId: string
1797
+ name: string
1798
+ tokenHash: string
1799
+ tokenPrefix: string
1800
+ scopes: string[]
1801
+ orgContext: string | null
1802
+ expiresAt: string
1803
+ }): Promise<PersonalAccessToken>
1804
+ /** The console's own list (newest first) — metadata only, never the
1805
+ * hash even (the list is a display surface). */
1806
+ listPersonalAccessTokens(userId: string): Promise<PersonalAccessToken[]>
1807
+ /** The org's token inventory (the org detail page's section): every
1808
+ * token whose holder carries an org_memberships row for the org —
1809
+ * ANY membership state (a disabled member's live token is exactly
1810
+ * what the oversight surface hunts). Metadata only. */
1811
+ listOrgPersonalAccessTokens(orgId: string): Promise<PersonalAccessToken[]>
1812
+ getPersonalAccessToken(id: string): Promise<PersonalAccessToken | null>
1813
+ /** The exchange's lookup: by the presented token's SHA-256. */
1814
+ findPersonalAccessTokenByHash(tokenHash: string): Promise<PersonalAccessToken | null>
1815
+ /** The revoke act (the owner's console): flips revoked_at/revoked_by
1816
+ * guarded on the LIVE row (a second revoke answers false; another
1817
+ * account's row answers false). The row STAYS — the audit + the org
1818
+ * inventory carry the history. */
1819
+ revokePersonalAccessToken(id: string, userId: string, revokedBy: string): Promise<boolean>
1820
+ /** The exchange path's throttled heartbeat: the caller decides the
1821
+ * throttle from the row it already read; the store stamps. auditAt
1822
+ * rides along when the heartbeat crossed the audit window; the
1823
+ * expiry-soon mailer's one-shot mark lands through expiryNotifiedAt. */
1824
+ stampPersonalAccessTokenUse(
1825
+ id: string,
1826
+ stamps: { usedAt: string; auditAt?: string | null; expiryNotifiedAt?: string | null },
1827
+ ): Promise<void>
1828
+
1609
1829
  // ── organization administration (TODO.identity/10) ──
1610
1830
  /** File a join request (the public "Request an account" page). */
1611
1831
  createOrgJoinRequest(input: {