@oimlsmart/platform-server 0.1.5 → 0.1.6

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);
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.6",
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
@@ -62,6 +62,7 @@ import {
62
62
  type InstrumentRegistration,
63
63
  type InstrumentRegistrationLifecycle,
64
64
  type InstrumentRegistrationScopeStatus,
65
+ type PersonalAccessToken,
65
66
  type PlatformEvent,
66
67
  resolveOrgContext,
67
68
  parseOrgMemberCone,
@@ -442,6 +443,41 @@ export class D1ServerStore implements ServerStore {
442
443
  return this.oidcColumnsEnsured
443
444
  }
444
445
 
446
+ // TODO.identity-features/08 (the personal access tokens): the
447
+ // personal_access_tokens table arrives with migration 0020 — a dev D1
448
+ // migrated from before it lacks the table, so the PAT methods ensure
449
+ // it defensively (the ensureOrgRegistrySupport posture, memoized per
450
+ // store).
451
+ private personalAccessTokenSupportEnsured: Promise<void> | null = null
452
+
453
+ private ensurePersonalAccessTokenSupport(): Promise<void> {
454
+ if (!this.personalAccessTokenSupportEnsured) {
455
+ this.personalAccessTokenSupportEnsured = (async () => {
456
+ await this.db.prepare(
457
+ `CREATE TABLE IF NOT EXISTS personal_access_tokens (
458
+ id TEXT PRIMARY KEY,
459
+ user_id TEXT NOT NULL REFERENCES users(id),
460
+ name TEXT NOT NULL,
461
+ token_hash TEXT NOT NULL,
462
+ token_prefix TEXT NOT NULL,
463
+ scopes TEXT NOT NULL DEFAULT '[]',
464
+ org_context TEXT,
465
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
466
+ expires_at TEXT NOT NULL,
467
+ last_used_at TEXT,
468
+ last_exchange_audit_at TEXT,
469
+ expiry_notified_at TEXT,
470
+ revoked_at TEXT,
471
+ revoked_by TEXT,
472
+ UNIQUE (token_hash)
473
+ )`,
474
+ ).run()
475
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_personal_access_tokens_user ON personal_access_tokens (user_id)').run()
476
+ })()
477
+ }
478
+ return this.personalAccessTokenSupportEnsured
479
+ }
480
+
445
481
  // ── users / sessions ─────────────────────────────────────────────
446
482
 
447
483
  async seedDemoAccounts(): Promise<void> {
@@ -1471,6 +1507,11 @@ export class D1ServerStore implements ServerStore {
1471
1507
  const recovery = await this.stmt('DELETE FROM recovery_codes WHERE user_id = ?', userId).run()
1472
1508
  const challenges = await this.stmt('DELETE FROM webauthn_challenges WHERE user_id = ?', userId).run()
1473
1509
  const mfa = await this.stmt('DELETE FROM mfa_pending WHERE user_id = ?', userId).run()
1510
+ // TODO.identity-features/08: the developer tokens die with the
1511
+ // account (the hashed rows go — a tombstone's tokens never exchange
1512
+ // again).
1513
+ await this.ensurePersonalAccessTokenSupport()
1514
+ const personalAccessTokens = await this.stmt('DELETE FROM personal_access_tokens WHERE user_id = ?', userId).run()
1474
1515
  await this.stmt(
1475
1516
  `UPDATE users SET
1476
1517
  email = ?, name = 'Deleted account', provider = 'erased',
@@ -1487,6 +1528,7 @@ export class D1ServerStore implements ServerStore {
1487
1528
  tokens: (passwords.meta.changes ?? 0) + (enrollments.meta.changes ?? 0) + (emailChanges.meta.changes ?? 0),
1488
1529
  factors: (passkeys.meta.changes ?? 0) + (totp.meta.changes ?? 0) + (recovery.meta.changes ?? 0)
1489
1530
  + (challenges.meta.changes ?? 0) + (mfa.meta.changes ?? 0),
1531
+ personalAccessTokens: personalAccessTokens.meta.changes ?? 0,
1490
1532
  }
1491
1533
  }
1492
1534
 
@@ -1843,6 +1885,92 @@ export class D1ServerStore implements ServerStore {
1843
1885
  return row ? D1ServerStore.toMfaPending(row) : null
1844
1886
  }
1845
1887
 
1888
+ // ── the personal access tokens (TODO.identity-features/08) ─────────
1889
+
1890
+ async createPersonalAccessToken(input: {
1891
+ id: string
1892
+ userId: string
1893
+ name: string
1894
+ tokenHash: string
1895
+ tokenPrefix: string
1896
+ scopes: string[]
1897
+ orgContext: string | null
1898
+ expiresAt: string
1899
+ }): Promise<PersonalAccessToken> {
1900
+ await this.ensurePersonalAccessTokenSupport()
1901
+ await this.stmt(
1902
+ `INSERT INTO personal_access_tokens
1903
+ (id, user_id, name, token_hash, token_prefix, scopes, org_context, expires_at)
1904
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1905
+ input.id, input.userId, input.name, input.tokenHash, input.tokenPrefix,
1906
+ JSON.stringify(input.scopes), input.orgContext, input.expiresAt,
1907
+ ).run()
1908
+ return (await this.getPersonalAccessToken(input.id))!
1909
+ }
1910
+
1911
+ async listPersonalAccessTokens(userId: string): Promise<PersonalAccessToken[]> {
1912
+ await this.ensurePersonalAccessTokenSupport()
1913
+ // created_at is second-resolution (datetime('now')) — the rowid
1914
+ // breaks the tie so the newest mint leads even within one second.
1915
+ const res = await this.stmt(
1916
+ 'SELECT * FROM personal_access_tokens WHERE user_id = ? ORDER BY created_at DESC, rowid DESC', userId,
1917
+ ).all<Record<string, unknown>>()
1918
+ return res.results.map(D1ServerStore.toPersonalAccessToken)
1919
+ }
1920
+
1921
+ async listOrgPersonalAccessTokens(orgId: string): Promise<PersonalAccessToken[]> {
1922
+ await this.ensurePersonalAccessTokenSupport()
1923
+ // The org inventory: every token whose holder carries a membership
1924
+ // row for the org (ANY state — a disabled member's live token is
1925
+ // exactly what the oversight surface hunts). org_memberships arrived
1926
+ // with 0011, long before the PAT table — the join needs no ensure of
1927
+ // its own beyond the membership support's.
1928
+ await this.ensureMembershipSupport()
1929
+ const res = await this.stmt(
1930
+ `SELECT p.* FROM personal_access_tokens p
1931
+ JOIN org_memberships m ON m.user_id = p.user_id
1932
+ WHERE m.org_id = ?
1933
+ ORDER BY p.created_at DESC, p.id`,
1934
+ orgId,
1935
+ ).all<Record<string, unknown>>()
1936
+ return res.results.map(D1ServerStore.toPersonalAccessToken)
1937
+ }
1938
+
1939
+ async getPersonalAccessToken(id: string): Promise<PersonalAccessToken | null> {
1940
+ await this.ensurePersonalAccessTokenSupport()
1941
+ const row = await this.stmt('SELECT * FROM personal_access_tokens WHERE id = ?', id).first<Record<string, unknown>>()
1942
+ return row ? D1ServerStore.toPersonalAccessToken(row) : null
1943
+ }
1944
+
1945
+ async findPersonalAccessTokenByHash(tokenHash: string): Promise<PersonalAccessToken | null> {
1946
+ await this.ensurePersonalAccessTokenSupport()
1947
+ const row = await this.stmt('SELECT * FROM personal_access_tokens WHERE token_hash = ?', tokenHash).first<Record<string, unknown>>()
1948
+ return row ? D1ServerStore.toPersonalAccessToken(row) : null
1949
+ }
1950
+
1951
+ async revokePersonalAccessToken(id: string, userId: string, revokedBy: string): Promise<boolean> {
1952
+ await this.ensurePersonalAccessTokenSupport()
1953
+ const res = await this.stmt(
1954
+ "UPDATE personal_access_tokens SET revoked_at = datetime('now'), revoked_by = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
1955
+ revokedBy, id, userId,
1956
+ ).run()
1957
+ return (res.meta.changes ?? 0) > 0
1958
+ }
1959
+
1960
+ async stampPersonalAccessTokenUse(
1961
+ id: string,
1962
+ stamps: { usedAt: string; auditAt?: string | null; expiryNotifiedAt?: string | null },
1963
+ ): Promise<void> {
1964
+ await this.ensurePersonalAccessTokenSupport()
1965
+ await this.stmt('UPDATE personal_access_tokens SET last_used_at = ? WHERE id = ?', stamps.usedAt, id).run()
1966
+ if (stamps.auditAt) {
1967
+ await this.stmt('UPDATE personal_access_tokens SET last_exchange_audit_at = ? WHERE id = ?', stamps.auditAt, id).run()
1968
+ }
1969
+ if (stamps.expiryNotifiedAt) {
1970
+ await this.stmt('UPDATE personal_access_tokens SET expiry_notified_at = ? WHERE id = ?', stamps.expiryNotifiedAt, id).run()
1971
+ }
1972
+ }
1973
+
1846
1974
  // ── organization administration (TODO.identity/10) ────────────────
1847
1975
 
1848
1976
  /** The store's time columns arrive in two shapes (datetime('now')'s
@@ -1910,6 +2038,28 @@ export class D1ServerStore implements ServerStore {
1910
2038
  }
1911
2039
  }
1912
2040
 
2041
+ /** The personal_access_tokens row → the seam's shape (TODO.identity-
2042
+ * features/08). The scopes cell parses defensively — a hand-edited
2043
+ * row's malformed JSON reads as the empty set, never trusted. */
2044
+ private static toPersonalAccessToken(row: Record<string, unknown>): PersonalAccessToken {
2045
+ return {
2046
+ id: row.id as string,
2047
+ userId: row.user_id as string,
2048
+ name: row.name as string,
2049
+ tokenHash: row.token_hash as string,
2050
+ tokenPrefix: row.token_prefix as string,
2051
+ scopes: parseRoles((row.scopes as string | null) ?? null) ?? [],
2052
+ orgContext: (row.org_context as string | null) ?? null,
2053
+ createdAt: D1ServerStore.storeTimeToIso(row.created_at as string)!,
2054
+ expiresAt: D1ServerStore.storeTimeToIso(row.expires_at as string)!,
2055
+ lastUsedAt: D1ServerStore.storeTimeToIso((row.last_used_at as string | null) ?? null),
2056
+ lastExchangeAuditAt: D1ServerStore.storeTimeToIso((row.last_exchange_audit_at as string | null) ?? null),
2057
+ expiryNotifiedAt: D1ServerStore.storeTimeToIso((row.expiry_notified_at as string | null) ?? null),
2058
+ revokedAt: D1ServerStore.storeTimeToIso((row.revoked_at as string | null) ?? null),
2059
+ revokedBy: (row.revoked_by as string | null) ?? null,
2060
+ }
2061
+ }
2062
+
1913
2063
  private static toOrgJoinRequest(row: Record<string, unknown>): OrgJoinRequest {
1914
2064
  return {
1915
2065
  id: row.id as string,
@@ -312,6 +312,7 @@ export function eraseOpAccount(userId: string): {
312
312
  memberships: number
313
313
  tokens: number
314
314
  factors: number
315
+ personalAccessTokens: number
315
316
  } | null {
316
317
  const db = getDb()
317
318
  const row = db.prepare('SELECT 1 AS ok FROM users WHERE id = ?').get(userId)
@@ -335,6 +336,9 @@ export function eraseOpAccount(userId: string): {
335
336
  db.prepare('DELETE FROM recovery_codes WHERE user_id = ?').run(userId).changes +
336
337
  db.prepare('DELETE FROM webauthn_challenges WHERE user_id = ?').run(userId).changes +
337
338
  db.prepare('DELETE FROM mfa_pending WHERE user_id = ?').run(userId).changes
339
+ // TODO.identity-features/08: the developer tokens die with the account
340
+ // (the hashed rows go — a tombstone's tokens never exchange again).
341
+ const personalAccessTokens = db.prepare('DELETE FROM personal_access_tokens WHERE user_id = ?').run(userId).changes
338
342
  db.prepare(
339
343
  `UPDATE users SET
340
344
  email = ?, name = 'Deleted account', provider = 'erased',
@@ -342,7 +346,7 @@ export function eraseOpAccount(userId: string): {
342
346
  avatar_url = NULL, email_verified_at = NULL, active = 0
343
347
  WHERE id = ?`,
344
348
  ).run(`deleted-${userId}@erased.invalid`, userId)
345
- return { ...revoked, links, clientRoles, memberships, tokens, factors }
349
+ return { ...revoked, links, clientRoles, memberships, tokens, factors, personalAccessTokens }
346
350
  }
347
351
 
348
352
  /** 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,41 @@ 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);
@@ -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 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,15 @@ 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'
199
208
 
200
209
 
201
210
  /** The workflow tables the reset wipe covers (the D1 store's
@@ -845,6 +854,41 @@ export function createSqliteServerStore(): ServerStore {
845
854
  return recordMfaPendingFailure(token)
846
855
  },
847
856
 
857
+ // ── the personal access tokens (TODO.identity-features/08) ──
858
+ async createPersonalAccessToken(input: {
859
+ id: string
860
+ userId: string
861
+ name: string
862
+ tokenHash: string
863
+ tokenPrefix: string
864
+ scopes: string[]
865
+ orgContext: string | null
866
+ expiresAt: string
867
+ }): Promise<PersonalAccessToken> {
868
+ return createPersonalAccessToken(input)
869
+ },
870
+ async listPersonalAccessTokens(userId: string): Promise<PersonalAccessToken[]> {
871
+ return listPersonalAccessTokens(userId)
872
+ },
873
+ async listOrgPersonalAccessTokens(orgId: string): Promise<PersonalAccessToken[]> {
874
+ return listOrgPersonalAccessTokens(orgId)
875
+ },
876
+ async getPersonalAccessToken(id: string): Promise<PersonalAccessToken | null> {
877
+ return getPersonalAccessToken(id)
878
+ },
879
+ async findPersonalAccessTokenByHash(tokenHash: string): Promise<PersonalAccessToken | null> {
880
+ return findPersonalAccessTokenByHash(tokenHash)
881
+ },
882
+ async revokePersonalAccessToken(id: string, userId: string, revokedBy: string): Promise<boolean> {
883
+ return revokePersonalAccessToken(id, userId, revokedBy)
884
+ },
885
+ async stampPersonalAccessTokenUse(
886
+ id: string,
887
+ stamps: { usedAt: string; auditAt?: string | null; expiryNotifiedAt?: string | null },
888
+ ): Promise<void> {
889
+ stampPersonalAccessTokenUse(id, stamps)
890
+ },
891
+
848
892
  // ── the central user registry (TODO.identity/03) ──
849
893
  async listOpClientRoles(userId: string): Promise<OpClientRoleAssignment[]> {
850
894
  return listOpClientRoles(userId)
package/src/store.ts CHANGED
@@ -549,7 +549,9 @@ export interface OpClientRoleAssignment {
549
549
  * credential/token rows (passwords, enrollment tokens, email-change
550
550
  * tokens). TODO.identity-sso/02+03 adds `factors`: the factor-registry
551
551
  * rows removed (passkeys, TOTP secrets, recovery codes, and the
552
- * account's pending ceremony state). */
552
+ * account's pending ceremony state). TODO.identity-features/08 adds
553
+ * `personalAccessTokens`: the developer-token rows (a dead account's
554
+ * tokens die with it). */
553
555
  export interface OpAccountErasure {
554
556
  sessions: number
555
557
  accessTokens: number
@@ -560,6 +562,7 @@ export interface OpAccountErasure {
560
562
  memberships: number
561
563
  tokens: number
562
564
  factors: number
565
+ personalAccessTokens: number
563
566
  }
564
567
 
565
568
  // ── the account console (TODO.identity/06) ───────────────────────────
@@ -676,6 +679,121 @@ export interface MfaPending {
676
679
  consumedAt: string | null
677
680
  }
678
681
 
682
+ // ── the personal access tokens (TODO.identity-features/08) ───────────
683
+ // The developer surface: an ACCOUNT-minted credential for programmatic
684
+ // access (the lab CLI, scripts, the agent pipelines). The GitHub
685
+ // fine-grained pattern mapped to the estate:
686
+ //
687
+ // - the PAT NEVER rides a request directly — it exchanges at the OP's
688
+ // token endpoint (the RFC 8693 grant, subject_token_type
689
+ // urn:oimlsmart:params:oauth:token-type:pat) for a short-lived OP
690
+ // JWT, so every relying party keeps validating the ONE token shape;
691
+ // - the plaintext shows ONCE at mint; the row holds only the SHA-256
692
+ // (256 bits of random — the recovery codes' unsalted-hash posture),
693
+ // and token_hash IS the exchange's lookup key;
694
+ // - expiration is MANDATORY (90 days default, 1 year the ceiling);
695
+ // - a token only ever NARROWS the account: its scopes are a subset of
696
+ // the holder's standing, enforced at mint AND re-judged at exchange;
697
+ // - never an ORG credential: org-level automation speaks the org's
698
+ // registered clients (the machine cone), never a person's token.
699
+
700
+ /** The PAT wire prefix (the GitHub `github_pat_` convention): the
701
+ * minted token is `${PAT_TOKEN_PREFIX}${43 base64url chars}` (32 random
702
+ * bytes). The prefix lets the exchange path recognize the cone and lets
703
+ * leak scanners catch a committed token. */
704
+ export const PAT_TOKEN_PREFIX = 'ospt_'
705
+
706
+ /** The action class's ordinality: admin ⊃ write ⊃ read. The RP's at-use
707
+ * check (patScopeCovers) reads it — a write token never mints admin
708
+ * acts, an admin token covers the read. */
709
+ export const PAT_ACTION_CLASSES = ['read', 'write', 'admin'] as const
710
+ export type PatActionClass = (typeof PAT_ACTION_CLASSES)[number]
711
+
712
+ /** One parsed scope: the service (a registered application-class OIDC
713
+ * client id — the estate's service registry IS the OP's client
714
+ * registry) × the action class. */
715
+ export interface PatScope {
716
+ service: string
717
+ action: PatActionClass
718
+ }
719
+
720
+ /** Parse one scope spelling ('<service>:<action-class>'). Total, never
721
+ * throws: a malformed spelling answers null (the mint refuses it, a
722
+ * stored row's malformed cell is skipped on read — never trusted). */
723
+ export function parsePatScope(raw: unknown): PatScope | null {
724
+ if (typeof raw !== 'string') return null
725
+ const m = /^([a-z0-9][a-z0-9._-]{0,127}):(read|write|admin)$/.exec(raw.trim())
726
+ if (!m) return null
727
+ return { service: m[1]!, action: m[2] as PatActionClass }
728
+ }
729
+
730
+ /** The canonical spelling (the store column's cell, the JWT's scope
731
+ * claim's word). */
732
+ export function encodePatScope(scope: PatScope): string {
733
+ return `${scope.service}:${scope.action}`
734
+ }
735
+
736
+ /** Normalize a scope set: parse every cell (a malformed cell refuses the
737
+ * WHOLE set at mint — null), drop duplicates, and fold a service's
738
+ * classes to the WIDEST (hub:read + hub:write is hub:write — the
739
+ * ordinal subsumes). The answer sorts for a stable wire/claim shape. */
740
+ export function normalizePatScopes(raw: unknown): PatScope[] | null {
741
+ if (!Array.isArray(raw) || raw.length === 0) return null
742
+ const widest = new Map<string, PatActionClass>()
743
+ for (const cell of raw) {
744
+ const scope = parsePatScope(cell)
745
+ if (!scope) return null
746
+ const held = widest.get(scope.service)
747
+ if (!held || PAT_ACTION_CLASSES.indexOf(scope.action) > PAT_ACTION_CLASSES.indexOf(held)) {
748
+ widest.set(scope.service, scope.action)
749
+ }
750
+ }
751
+ return [...widest.entries()]
752
+ .map(([service, action]) => ({ service, action }))
753
+ .sort((a, b) => a.service.localeCompare(b.service))
754
+ }
755
+
756
+ /** THE NARROWING INVARIANT (the spec's core: scopes ≤ the holder's,
757
+ * enforced at exchange AND at use): every granted scope must be covered
758
+ * by the ceiling set — the account's current standing at mint/exchange,
759
+ * the token's own pinned set when a caller narrows per exchange. */
760
+ export function patScopesWithin(granted: readonly PatScope[], ceiling: readonly PatScope[]): boolean {
761
+ return granted.every(g => patScopeCovers(ceiling, g.service, g.action))
762
+ }
763
+
764
+ /** The at-use check (the RP's bearer gate — the RBAC map's token-scope
765
+ * cone): does the granted set cover this service at this action class?
766
+ * Ordinal: a wider class covers the narrower. */
767
+ export function patScopeCovers(granted: readonly PatScope[], service: string, action: PatActionClass): boolean {
768
+ const need = PAT_ACTION_CLASSES.indexOf(action)
769
+ return granted.some(g => g.service === service && PAT_ACTION_CLASSES.indexOf(g.action) >= need)
770
+ }
771
+
772
+ /** A personal access token's row (the personal_access_tokens table).
773
+ * NEVER the plaintext, never reversible material: tokenHash is the
774
+ * SHA-256 lookup key, tokenPrefix the console's display fragment.
775
+ * orgContext pins the mint's active-org context (null = the account's
776
+ * primary); lastUsedAt / lastExchangeAuditAt carry the exchange path's
777
+ * throttled heartbeat; expiryNotifiedAt the expiry-soon mailer's
778
+ * one-shot mark. */
779
+ export interface PersonalAccessToken {
780
+ id: string
781
+ userId: string
782
+ name: string
783
+ tokenHash: string
784
+ tokenPrefix: string
785
+ /** The pinned scope set (the encoded spellings, normalized at mint). */
786
+ scopes: string[]
787
+ orgContext: string | null
788
+ createdAt: string
789
+ expiresAt: string
790
+ lastUsedAt: string | null
791
+ lastExchangeAuditAt: string | null
792
+ expiryNotifiedAt: string | null
793
+ revokedAt: string | null
794
+ revokedBy: string | null
795
+ }
796
+
679
797
 
680
798
  // ── organization administration (TODO.identity/10) ───────────────────
681
799
 
@@ -1606,6 +1724,46 @@ export interface ServerStore {
1606
1724
  * fresh row (null when the token is gone). */
1607
1725
  recordMfaPendingFailure(token: string): Promise<MfaPending | null>
1608
1726
 
1727
+ // ── the personal access tokens (TODO.identity-features/08) ──
1728
+ /** The mint: one row per token. The plaintext NEVER crosses the seam —
1729
+ * the caller hashes (SHA-256) and the row holds the hash + the
1730
+ * display prefix. expiresAt is mandatory (the route enforces the
1731
+ * 1-year ceiling; the store trusts the route's arithmetic). */
1732
+ createPersonalAccessToken(input: {
1733
+ id: string
1734
+ userId: string
1735
+ name: string
1736
+ tokenHash: string
1737
+ tokenPrefix: string
1738
+ scopes: string[]
1739
+ orgContext: string | null
1740
+ expiresAt: string
1741
+ }): Promise<PersonalAccessToken>
1742
+ /** The console's own list (newest first) — metadata only, never the
1743
+ * hash even (the list is a display surface). */
1744
+ listPersonalAccessTokens(userId: string): Promise<PersonalAccessToken[]>
1745
+ /** The org's token inventory (the org detail page's section): every
1746
+ * token whose holder carries an org_memberships row for the org —
1747
+ * ANY membership state (a disabled member's live token is exactly
1748
+ * what the oversight surface hunts). Metadata only. */
1749
+ listOrgPersonalAccessTokens(orgId: string): Promise<PersonalAccessToken[]>
1750
+ getPersonalAccessToken(id: string): Promise<PersonalAccessToken | null>
1751
+ /** The exchange's lookup: by the presented token's SHA-256. */
1752
+ findPersonalAccessTokenByHash(tokenHash: string): Promise<PersonalAccessToken | null>
1753
+ /** The revoke act (the owner's console): flips revoked_at/revoked_by
1754
+ * guarded on the LIVE row (a second revoke answers false; another
1755
+ * account's row answers false). The row STAYS — the audit + the org
1756
+ * inventory carry the history. */
1757
+ revokePersonalAccessToken(id: string, userId: string, revokedBy: string): Promise<boolean>
1758
+ /** The exchange path's throttled heartbeat: the caller decides the
1759
+ * throttle from the row it already read; the store stamps. auditAt
1760
+ * rides along when the heartbeat crossed the audit window; the
1761
+ * expiry-soon mailer's one-shot mark lands through expiryNotifiedAt. */
1762
+ stampPersonalAccessTokenUse(
1763
+ id: string,
1764
+ stamps: { usedAt: string; auditAt?: string | null; expiryNotifiedAt?: string | null },
1765
+ ): Promise<void>
1766
+
1609
1767
  // ── organization administration (TODO.identity/10) ──
1610
1768
  /** File a join request (the public "Request an account" page). */
1611
1769
  createOrgJoinRequest(input: {