@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.
- package/migrations/0020_personal_access_tokens.sql +51 -0
- package/migrations/0021_oidc_consent_grants.sql +44 -0
- package/package.json +1 -1
- package/src/store/d1.ts +259 -0
- package/src/store/sqlite/consent-grants-store.ts +85 -0
- package/src/store/sqlite/op-accounts-store.ts +9 -1
- package/src/store/sqlite/pat-store.ts +127 -0
- package/src/store/sqlite/schema.sql +62 -0
- package/src/store/sqlite.ts +65 -1
- package/src/store.ts +221 -1
|
@@ -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.
|
|
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);
|
package/src/store/sqlite.ts
CHANGED
|
@@ -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: {
|