@oimlsmart/platform-server 0.1.4 → 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.
- package/migrations/0020_personal_access_tokens.sql +51 -0
- package/package.json +1 -1
- package/src/store/d1.ts +150 -0
- package/src/store/sqlite/op-accounts-store.ts +5 -1
- package/src/store/sqlite/pat-store.ts +127 -0
- package/src/store/sqlite/schema.sql +38 -0
- package/src/store/sqlite.ts +45 -1
- package/src/store.ts +159 -1
- package/src/vocab/permissions.ts +32 -0
|
@@ -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.
|
|
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);
|
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 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: {
|
package/src/vocab/permissions.ts
CHANGED
|
@@ -145,6 +145,12 @@ export const TRANSITION_PERMISSIONS: Record<string, readonly ActionPermission[]>
|
|
|
145
145
|
// records mode (TODO.adoption/02): the registered-offline entry — the
|
|
146
146
|
// application's honest hop into ACCEPTED without a fake walked history.
|
|
147
147
|
'application:register_offline->ACCEPTED': ['records.register'],
|
|
148
|
+
// DEMO_FLOWS wave 1: the lifecycle closure — the IA marks the Evaluation
|
|
149
|
+
// Project COMPLETED once the completion gate's settling conditions all
|
|
150
|
+
// hold (the model's completion_settled guard refuses otherwise); the act
|
|
151
|
+
// is the IA's decision desk on the application, the same family as the
|
|
152
|
+
// accept/reject decisions.
|
|
153
|
+
'application:ia_marks_completed->COMPLETED': ['application.accept'],
|
|
148
154
|
|
|
149
155
|
// test_request (the dispatch; TODO.adoption/07's quote leg: the
|
|
150
156
|
// laboratory's quotation response and the IA's commercial decision on it)
|
|
@@ -161,6 +167,9 @@ export const TRANSITION_PERMISSIONS: Record<string, readonly ActionPermission[]>
|
|
|
161
167
|
// test_report
|
|
162
168
|
'test_report:lab_submits->SUBMITTED': ['tr.submit'],
|
|
163
169
|
'test_report:ia_starts_review->UNDER_REVIEW': ['tr.review'],
|
|
170
|
+
// DEMO_FLOWS wave 1: the review period's opening on the test report —
|
|
171
|
+
// the IA's TR-review desk (the consultation rides the review class).
|
|
172
|
+
'test_report:ia_opens_consultation->CONSULTATION': ['tr.review'],
|
|
164
173
|
'test_report:ia_accepts->ACCEPTED': ['tr.review'],
|
|
165
174
|
'test_report:ia_rejects->REJECTED': ['tr.review'],
|
|
166
175
|
'test_report:lab_withdraws_for_edit->DRAFT': ['tr.submit'],
|
|
@@ -184,6 +193,9 @@ export const TRANSITION_PERMISSIONS: Record<string, readonly ActionPermission[]>
|
|
|
184
193
|
// Fired by the TR-review service when the last report is determined —
|
|
185
194
|
// the reviewing officer holds tr.review, the evaluation worker er.review.
|
|
186
195
|
'evaluation_report:last_tr_determined->ALL_TR_REVIEWED': ['tr.review', 'er.review'],
|
|
196
|
+
// DEMO_FLOWS wave 1: the review period's opening — the IA officer's
|
|
197
|
+
// evaluation-work act (the consultation is the ER's review surface).
|
|
198
|
+
'evaluation_report:ia_opens_consultation->CONSULTATION': ['er.review'],
|
|
187
199
|
'evaluation_report:ia_approves->APPROVED': ['er.finalize'],
|
|
188
200
|
'evaluation_report:ia_rejects->REJECTED': ['er.finalize'],
|
|
189
201
|
'evaluation_report:ia_approves_with_conditions->CONDITIONALLY_APPROVED': ['er.finalize'],
|
|
@@ -217,6 +229,22 @@ export const TRANSITION_PERMISSIONS: Record<string, readonly ActionPermission[]>
|
|
|
217
229
|
'certificate:revise->ACTIVE': ['certificate.issue'],
|
|
218
230
|
'certificate:transfer_ownership->ACTIVE': ['certificate.issue'],
|
|
219
231
|
|
|
232
|
+
// certificate_registration (DEMO_FLOWS wave 1 — the register's
|
|
233
|
+
// auto-publish doctrine, the program owner's 2026-08-28 decision): the
|
|
234
|
+
// hub-side registration submission lifecycle — the registrant
|
|
235
|
+
// announcement's shell, the signed package's intake verification, the
|
|
236
|
+
// publication, the recorded refusal. All three edges are SYSTEM acts:
|
|
237
|
+
// the intake automation walks them on the verified signed submission
|
|
238
|
+
// (no human gate between verify and publish — the register's trust is
|
|
239
|
+
// in the signature + the receipt machinery; the human review surfaces
|
|
240
|
+
// stay read postures). The map names the desk authority the automation
|
|
241
|
+
// exercises — the same shape the other engine-fired edges ride (the
|
|
242
|
+
// sample_verification projection's transitions name
|
|
243
|
+
// verification.perform).
|
|
244
|
+
'certificate_registration:intake_verifies->SUBMITTED': ['certificate.register'],
|
|
245
|
+
'certificate_registration:auto_publishes->PUBLISHED': ['certificate.register'],
|
|
246
|
+
'certificate_registration:intake_refuses->REFUSED': ['certificate.register'],
|
|
247
|
+
|
|
220
248
|
// test_assignment (the laboratory's work items; `omit` rides the IA's
|
|
221
249
|
// dispatch withdrawal cascade)
|
|
222
250
|
'test_assignment:lab_accepts->ACCEPTED': ['run.perform'],
|
|
@@ -354,6 +382,10 @@ export const STORE_MACHINES: Record<string, string> = {
|
|
|
354
382
|
formInstances: 'form_instance',
|
|
355
383
|
evaluationReports: 'evaluation_report',
|
|
356
384
|
certificates: 'certificate',
|
|
385
|
+
// DEMO_FLOWS wave 1: the register's registration submissions are
|
|
386
|
+
// machinated too (the intake-verify → auto-publish chain); the generic
|
|
387
|
+
// write path gates status writes on the machine's declared edges.
|
|
388
|
+
certificateRegistrations: 'certificate_registration',
|
|
357
389
|
testAssignments: 'test_assignment',
|
|
358
390
|
testRuns: 'test_run',
|
|
359
391
|
measuringInstrumentSamples: 'measuring_instrument_sample',
|