@openparachute/hub 0.5.7 → 0.5.10-rc.2

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.
Files changed (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +70 -323
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  12. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  13. package/src/__tests__/expose.test.ts +2 -2
  14. package/src/__tests__/hub-server.test.ts +526 -67
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/jwt-sign.test.ts +205 -0
  18. package/src/__tests__/module-manifest.test.ts +48 -0
  19. package/src/__tests__/oauth-handlers.test.ts +375 -5
  20. package/src/__tests__/operator-token.test.ts +427 -3
  21. package/src/__tests__/origin-check.test.ts +220 -0
  22. package/src/__tests__/serve.test.ts +100 -0
  23. package/src/__tests__/setup-gate.test.ts +196 -0
  24. package/src/__tests__/status.test.ts +199 -0
  25. package/src/__tests__/supervisor.test.ts +408 -0
  26. package/src/__tests__/upgrade.test.ts +247 -4
  27. package/src/__tests__/well-known.test.ts +69 -0
  28. package/src/admin-clients.ts +139 -0
  29. package/src/admin-handlers.ts +32 -254
  30. package/src/admin-host-admin-token.ts +25 -10
  31. package/src/admin-login-ui.ts +256 -0
  32. package/src/admin-vault-admin-token.ts +1 -1
  33. package/src/api-me.ts +124 -0
  34. package/src/api-mint-token.ts +239 -0
  35. package/src/api-revocation-list.ts +59 -0
  36. package/src/api-revoke-token.ts +153 -0
  37. package/src/api-tokens.ts +224 -0
  38. package/src/cli.ts +28 -0
  39. package/src/commands/auth.ts +408 -51
  40. package/src/commands/expose-2fa-warning.ts +6 -6
  41. package/src/commands/serve.ts +157 -0
  42. package/src/commands/status.ts +74 -10
  43. package/src/commands/upgrade.ts +33 -6
  44. package/src/csrf.ts +6 -3
  45. package/src/help.ts +54 -5
  46. package/src/hub-control.ts +1 -0
  47. package/src/hub-db.ts +63 -0
  48. package/src/hub-server.ts +630 -135
  49. package/src/hub.ts +272 -149
  50. package/src/install-source.ts +291 -0
  51. package/src/jwt-sign.ts +265 -5
  52. package/src/module-manifest.ts +48 -10
  53. package/src/oauth-handlers.ts +238 -54
  54. package/src/oauth-ui.ts +23 -2
  55. package/src/operator-token.ts +349 -18
  56. package/src/origin-check.ts +127 -0
  57. package/src/rate-limit.ts +5 -2
  58. package/src/scope-explanations.ts +33 -2
  59. package/src/sessions.ts +1 -1
  60. package/src/supervisor.ts +359 -0
  61. package/src/well-known.ts +54 -1
  62. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  63. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  64. package/web/ui/dist/index.html +2 -2
  65. package/src/__tests__/admin-config.test.ts +0 -281
  66. package/src/admin-config-ui.ts +0 -534
  67. package/src/admin-config.ts +0 -226
  68. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  69. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Detects where each service is *running from* — bun-linked against a local
3
+ * checkout, or installed from npm — so `parachute status` can surface the
4
+ * provenance alongside version/health.
5
+ *
6
+ * Motivation: hub#243. After a bun-linked rebuild, `services.json`'s cached
7
+ * `version` field can lag the live `package.json` version, and the operator
8
+ * has no way to spot the drift from `status` output alone. Surfacing
9
+ * install-source + a STALE flag turns a three-step diagnosis into one
10
+ * glance.
11
+ *
12
+ * Pure read path: filesystem + (optional) one-shot `git rev-parse` per
13
+ * service. No network. Every external dependency is injectable via the
14
+ * Deps bag so tests don't touch real bun-globals or git.
15
+ */
16
+ import { execFileSync } from "node:child_process";
17
+ import { readFileSync, realpathSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { dirname, join, resolve } from "node:path";
20
+ import { FIRST_PARTY_FALLBACKS, shortNameForManifest } from "./service-spec.ts";
21
+
22
+ export type InstallSourceKind = "bun-linked" | "npm" | "unknown";
23
+
24
+ export interface InstallSource {
25
+ readonly kind: InstallSourceKind;
26
+ /**
27
+ * Absolute path to the source checkout (bun-linked) or the installed
28
+ * package dir under bun globals (npm). Undefined when `kind === "unknown"`.
29
+ */
30
+ readonly path?: string;
31
+ /** Short git HEAD hash for bun-linked sources where the path is a git repo. */
32
+ readonly gitHead?: string;
33
+ /**
34
+ * Version from the live `package.json` at `path`. For bun-linked sources
35
+ * this can differ from the entry's cached `services.json.version` — that's
36
+ * the drift case we surface.
37
+ */
38
+ readonly livePackageVersion?: string;
39
+ }
40
+
41
+ export interface DetectInstallSourceDeps {
42
+ /**
43
+ * Returns the absolute path the bun-global symlink for `packageName` points
44
+ * at, or null if no symlink/install exists at any known prefix. Mirrors
45
+ * `defaultLinkedPath` in commands/install.ts so the contracts stay aligned.
46
+ */
47
+ readonly resolveBunGlobal?: (packageName: string) => string | null;
48
+ /** Returns the bun-global node_modules prefixes to consider "npm-installed". */
49
+ readonly bunGlobalPrefixes?: () => readonly string[];
50
+ /** Reads + parses a JSON file. Test seam — keeps the module synchronously testable. */
51
+ readonly readJson?: (path: string) => unknown;
52
+ /** Returns the short git HEAD at `path`, or undefined if unavailable. */
53
+ readonly readGitHead?: (path: string) => string | undefined;
54
+ }
55
+
56
+ export function bunGlobalPrefixes(): readonly string[] {
57
+ const prefixes: string[] = [];
58
+ const fromEnv = process.env.BUN_INSTALL;
59
+ if (fromEnv) prefixes.push(join(fromEnv, "install", "global", "node_modules"));
60
+ prefixes.push(join(homedir(), ".bun", "install", "global", "node_modules"));
61
+ return prefixes;
62
+ }
63
+
64
+ export function defaultResolveBunGlobal(packageName: string): string | null {
65
+ for (const prefix of bunGlobalPrefixes()) {
66
+ const pkgJson = join(prefix, ...packageName.split("/"), "package.json");
67
+ try {
68
+ return dirname(realpathSync(pkgJson));
69
+ } catch {
70
+ // Try the next prefix.
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ export function defaultReadJson(path: string): unknown {
77
+ return JSON.parse(readFileSync(path, "utf8"));
78
+ }
79
+
80
+ export function defaultReadGitHead(path: string): string | undefined {
81
+ try {
82
+ const out = execFileSync("git", ["-C", path, "rev-parse", "--short", "HEAD"], {
83
+ encoding: "utf8",
84
+ stdio: ["ignore", "pipe", "ignore"],
85
+ timeout: 1500,
86
+ });
87
+ const head = out.trim();
88
+ return head.length > 0 ? head : undefined;
89
+ } catch {
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * True when `candidate` is under one of the bun-global prefixes. Both sides
96
+ * are realpath'd so symlinks in `candidate` (or in a parent of the prefix)
97
+ * don't make us miss the match. Used to classify "is this installed in bun
98
+ * globals" vs "is this a separate checkout that bun-link points at."
99
+ */
100
+ function isUnderBunGlobals(candidate: string, prefixes: readonly string[]): boolean {
101
+ let cand: string;
102
+ try {
103
+ cand = realpathSync(candidate);
104
+ } catch {
105
+ cand = resolve(candidate);
106
+ }
107
+ for (const prefix of prefixes) {
108
+ let pre: string;
109
+ try {
110
+ pre = realpathSync(prefix);
111
+ } catch {
112
+ pre = resolve(prefix);
113
+ }
114
+ if (cand === pre) return true;
115
+ const withSep = pre.endsWith("/") ? pre : `${pre}/`;
116
+ if (cand.startsWith(withSep)) return true;
117
+ }
118
+ return false;
119
+ }
120
+
121
+ function packageNameFor(entryName: string): string | undefined {
122
+ const short = shortNameForManifest(entryName);
123
+ if (short !== undefined) {
124
+ const fb = FIRST_PARTY_FALLBACKS[short];
125
+ if (fb) return fb.package;
126
+ }
127
+ return undefined;
128
+ }
129
+
130
+ function readVersion(packageDir: string, readJson: (p: string) => unknown): string | undefined {
131
+ try {
132
+ const parsed = readJson(join(packageDir, "package.json"));
133
+ if (parsed && typeof parsed === "object") {
134
+ const v = (parsed as Record<string, unknown>).version;
135
+ if (typeof v === "string" && v.length > 0) return v;
136
+ }
137
+ } catch {
138
+ // package.json missing / malformed — leave undefined so the caller can
139
+ // still report kind without inventing a version.
140
+ }
141
+ return undefined;
142
+ }
143
+
144
+ export interface DetectArgs {
145
+ /** The services.json row name (`parachute-vault`, `agent`, etc.). */
146
+ readonly entryName: string;
147
+ /** Absolute install dir from services.json, when known. */
148
+ readonly installDir?: string;
149
+ }
150
+
151
+ /**
152
+ * Classify a service's install source. Pure: no network, single optional
153
+ * `git rev-parse` shell-out (mock via `readGitHead` in tests).
154
+ *
155
+ * Resolution order:
156
+ * 1. If `installDir` is set: realpath + compare against bun globals. Under
157
+ * globals → `npm`; anywhere else → `bun-linked`.
158
+ * 2. Else, if we can map the entry to a first-party package name: look up
159
+ * the bun-global symlink target. Bare `bun add -g <pkg>` lands under
160
+ * bun globals → `npm`; `bun link` lands somewhere else → `bun-linked`.
161
+ * 3. Else: `unknown` — third-party rows missing `installDir` (legacy
162
+ * manifest from before installDir stamping) fall here. They should be
163
+ * rare and the operator's signal is "re-install to refresh."
164
+ */
165
+ export function detectInstallSource(
166
+ args: DetectArgs,
167
+ deps: DetectInstallSourceDeps = {},
168
+ ): InstallSource {
169
+ const resolveBunGlobal = deps.resolveBunGlobal ?? defaultResolveBunGlobal;
170
+ const prefixes = deps.bunGlobalPrefixes ?? bunGlobalPrefixes;
171
+ const readJson = deps.readJson ?? defaultReadJson;
172
+ const readGitHead = deps.readGitHead ?? defaultReadGitHead;
173
+
174
+ const candidate =
175
+ args.installDir ??
176
+ (() => {
177
+ const pkg = packageNameFor(args.entryName);
178
+ return pkg ? resolveBunGlobal(pkg) : null;
179
+ })();
180
+
181
+ if (!candidate) return { kind: "unknown" };
182
+
183
+ let resolvedPath: string;
184
+ try {
185
+ resolvedPath = realpathSync(candidate);
186
+ } catch {
187
+ resolvedPath = resolve(candidate);
188
+ }
189
+
190
+ const underGlobals = isUnderBunGlobals(resolvedPath, prefixes());
191
+ const livePackageVersion = readVersion(resolvedPath, readJson);
192
+
193
+ if (underGlobals) {
194
+ return {
195
+ kind: "npm",
196
+ path: resolvedPath,
197
+ ...(livePackageVersion !== undefined && { livePackageVersion }),
198
+ };
199
+ }
200
+
201
+ const gitHead = readGitHead(resolvedPath);
202
+ return {
203
+ kind: "bun-linked",
204
+ path: resolvedPath,
205
+ ...(livePackageVersion !== undefined && { livePackageVersion }),
206
+ ...(gitHead !== undefined && { gitHead }),
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Detect the hub's own install source from the running process. The hub
212
+ * doesn't have a services.json row of its own, but `parachute status` still
213
+ * surfaces a row for it — so the same SOURCE column has to render. We
214
+ * climb from `import.meta.dir` (the location of the running source files)
215
+ * to the nearest `package.json`, then classify that path the same way we
216
+ * classify a service's `installDir`.
217
+ *
218
+ * `srcDir` is injectable so tests can drive the function without depending
219
+ * on `import.meta.dir` (which points at the test file, not at hub source).
220
+ */
221
+ export function detectHubInstallSource(
222
+ srcDir: string,
223
+ deps: DetectInstallSourceDeps = {},
224
+ ): InstallSource {
225
+ // `import.meta.dir` is `<pkgDir>/src` in normal layouts.
226
+ const pkgDir = findNearestPackageDir(srcDir, deps.readJson ?? defaultReadJson);
227
+ if (!pkgDir) return { kind: "unknown" };
228
+ return detectInstallSource({ entryName: "parachute-hub", installDir: pkgDir }, deps);
229
+ }
230
+
231
+ function findNearestPackageDir(
232
+ start: string,
233
+ readJson: (p: string) => unknown,
234
+ ): string | undefined {
235
+ let current = resolve(start);
236
+ for (let i = 0; i < 16; i++) {
237
+ try {
238
+ readJson(join(current, "package.json"));
239
+ return current;
240
+ } catch {
241
+ // No package.json here — climb.
242
+ }
243
+ const parent = dirname(current);
244
+ if (parent === current) return undefined;
245
+ current = parent;
246
+ }
247
+ return undefined;
248
+ }
249
+
250
+ /**
251
+ * True when an entry's cached `services.json` version differs from the live
252
+ * `package.json` version at its bun-linked path. The single drift case the
253
+ * operator can act on — `parachute upgrade <svc>` for the bun-linked path
254
+ * doesn't refresh `services.json` on rebuild, so a freshly-built source can
255
+ * still report the pre-rebuild version through status.
256
+ *
257
+ * Only meaningful for `kind === "bun-linked"`. NPM-installed services
258
+ * don't have a "live" source separate from the cached version.
259
+ */
260
+ export function isStale(entryVersion: string, source: InstallSource): boolean {
261
+ if (source.kind !== "bun-linked") return false;
262
+ if (!source.livePackageVersion) return false;
263
+ return source.livePackageVersion !== entryVersion;
264
+ }
265
+
266
+ /**
267
+ * Compact `SOURCE` cell label for the status table. Verbose-friendly, never
268
+ * wider than ~50 chars on typical inputs.
269
+ *
270
+ * bun-linked: `bun-linked → <basename> @ <head>` (head is the git short SHA)
271
+ * npm: `npm (0.3.15-rc.1)` / `npm` (version when known)
272
+ * unknown: `unknown`
273
+ *
274
+ * The continuation `STALE` indicator is a separate line in the status
275
+ * renderer — keeps the column narrow.
276
+ */
277
+ export function formatInstallSourceLabel(source: InstallSource): string {
278
+ if (source.kind === "bun-linked") {
279
+ const basename = source.path
280
+ ? (source.path.split("/").filter(Boolean).pop() ?? source.path)
281
+ : undefined;
282
+ if (basename && source.gitHead) return `bun-linked → ${basename} @ ${source.gitHead}`;
283
+ if (basename) return `bun-linked → ${basename}`;
284
+ return "bun-linked";
285
+ }
286
+ if (source.kind === "npm") {
287
+ if (source.livePackageVersion) return `npm (${source.livePackageVersion})`;
288
+ return "npm";
289
+ }
290
+ return "unknown";
291
+ }
package/src/jwt-sign.ts CHANGED
@@ -51,10 +51,18 @@ export interface SignAccessTokenOpts {
51
51
  jti?: string;
52
52
  /**
53
53
  * Override the default 15-minute access-token TTL. Long-lived tokens
54
- * (operator-token, ~1y) pass an explicit value here.
54
+ * (operator-token, ~90d) pass an explicit value here.
55
55
  */
56
56
  ttlSeconds?: number;
57
57
  now?: () => Date;
58
+ /**
59
+ * Extra JWT claims merged into the payload. Used by operator-token to embed
60
+ * `pa_scope_set` (which scope-set the token was minted under) so an
61
+ * auto-rotation can preserve the operator's chosen narrowing across mints.
62
+ * Reserved claims (`scope`, `client_id`, `sub`, `iss`, `iat`, `exp`, `aud`,
63
+ * `jti`) are owned by this function and overwritten if passed here.
64
+ */
65
+ extraClaims?: Record<string, unknown>;
58
66
  }
59
67
 
60
68
  export interface SignedAccessToken {
@@ -74,6 +82,7 @@ export async function signAccessToken(
74
82
  const iat = Math.floor(nowMs / 1000);
75
83
  const exp = iat + (opts.ttlSeconds ?? ACCESS_TOKEN_TTL_SECONDS);
76
84
  const token = await new SignJWT({
85
+ ...(opts.extraClaims ?? {}),
77
86
  scope: opts.scopes.join(" "),
78
87
  client_id: opts.clientId,
79
88
  })
@@ -104,6 +113,15 @@ export interface SignRefreshTokenOpts {
104
113
  now?: () => Date;
105
114
  }
106
115
 
116
+ /**
117
+ * Provenance of a `tokens` row — which mint path wrote it. Drives the
118
+ * unified token registry semantics introduced in v6 (hub#212 Phase 1):
119
+ * one table for refresh tokens, one for CLI-minted access tokens, one
120
+ * for operator tokens. Different mint paths = different rows; revocation
121
+ * lookup + revocation list are uniform across all of them.
122
+ */
123
+ export type TokenCreatedVia = "oauth_refresh" | "cli_mint" | "operator_mint";
124
+
107
125
  export interface SignedRefreshToken {
108
126
  /** Opaque token to return to the client. NOT recoverable from the DB. */
109
127
  token: string;
@@ -138,8 +156,8 @@ export function signRefreshToken(db: Database, opts: SignRefreshTokenOpts): Sign
138
156
  const familyId = opts.familyId ?? randomUUID();
139
157
  try {
140
158
  db.prepare(
141
- `INSERT INTO tokens (jti, user_id, client_id, scopes, refresh_token_hash, family_id, expires_at, created_at)
142
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
159
+ `INSERT INTO tokens (jti, user_id, client_id, scopes, refresh_token_hash, family_id, expires_at, created_at, created_via)
160
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'oauth_refresh')`,
143
161
  ).run(
144
162
  opts.jti,
145
163
  opts.userId,
@@ -159,6 +177,208 @@ export function signRefreshToken(db: Database, opts: SignRefreshTokenOpts): Sign
159
177
  return { token, refreshTokenHash, familyId, expiresAt };
160
178
  }
161
179
 
180
+ export interface RecordTokenMintOpts {
181
+ /** Same as the issued JWT's jti — keys the row. */
182
+ jti: string;
183
+ /** Provenance tag — drives admin UI grouping and the registry semantics. */
184
+ createdVia: Exclude<TokenCreatedVia, "oauth_refresh">;
185
+ /** Subject identity for non-user mints. Operator-mint rows pass "operator"; service-mint rows pass the service short name. */
186
+ subject: string;
187
+ /** Optional user_id when the mint was performed against a hub user (the mint-token CLI flow defaults to the operator's user). NULL for purely service-tied mints. */
188
+ userId?: string;
189
+ clientId: string;
190
+ scopes: string[];
191
+ /** ISO-8601 expiry. Same value as the JWT's `exp`. */
192
+ expiresAt: string;
193
+ /** Optional JSON-encoded permissions claim (per auth-architecture-shape.md §11.3). */
194
+ permissions?: string;
195
+ now?: () => Date;
196
+ }
197
+
198
+ /**
199
+ * Write a `tokens` row for a non-OAuth-refresh mint. The OAuth refresh
200
+ * path goes through `signRefreshToken` (which already inserts); this path
201
+ * is for CLI mint-token, the new POST /api/auth/mint-token endpoint, and
202
+ * operator-token mints (rotate-operator + the auto-rotation helper from
203
+ * #213).
204
+ *
205
+ * Every issued JWT must have a row in this table going forward — that's
206
+ * the contract the revocation list endpoint depends on. Pre-Phase-1
207
+ * tokens (already issued before this migration) are unregistered but
208
+ * harmless: they expire on their own (15-min access tokens drained
209
+ * within the hour; 365d operator tokens cap at their original expiry,
210
+ * with the new 90d auto-rotation taking over once an operator runs the
211
+ * CLI again).
212
+ */
213
+ export function recordTokenMint(db: Database, opts: RecordTokenMintOpts): void {
214
+ const now = opts.now?.() ?? new Date();
215
+ try {
216
+ db.prepare(
217
+ `INSERT INTO tokens (
218
+ jti, user_id, client_id, scopes, expires_at, created_at,
219
+ permissions, created_via, subject
220
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
221
+ ).run(
222
+ opts.jti,
223
+ opts.userId ?? null,
224
+ opts.clientId,
225
+ opts.scopes.join(" "),
226
+ opts.expiresAt,
227
+ now.toISOString(),
228
+ opts.permissions ?? null,
229
+ opts.createdVia,
230
+ opts.subject,
231
+ );
232
+ } catch (err) {
233
+ throw new RefreshTokenInsertError(
234
+ `failed to insert token registry row (jti=${opts.jti}, created_via=${opts.createdVia}): ${err instanceof Error ? err.message : String(err)}`,
235
+ err,
236
+ );
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Mark a `tokens` row revoked by jti. Idempotent: a row already revoked
242
+ * keeps its existing `revoked_at`. Returns true when a row was updated
243
+ * (was un-revoked before), false when no row matches or the row was
244
+ * already revoked. Used by the new admin revoke path, the operator-mint
245
+ * rotation cleanup, and any future explicit-revoke surface.
246
+ */
247
+ export function revokeTokenByJti(db: Database, jti: string, now: Date): boolean {
248
+ const res = db
249
+ .prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ? AND revoked_at IS NULL")
250
+ .run(now.toISOString(), jti);
251
+ return Number(res.changes) > 0;
252
+ }
253
+
254
+ /**
255
+ * Snapshot of currently-revoked-and-not-yet-expired jtis. Powers the
256
+ * `/.well-known/parachute-revocation.json` endpoint. Already-expired jtis
257
+ * are filtered out (no need to advertise the obvious — every consumer
258
+ * checks `exp` itself; the revocation list exists for *unexpired*
259
+ * tokens whose validity got cut short).
260
+ */
261
+ export function listActiveRevocations(db: Database, now: Date): string[] {
262
+ const rows = db
263
+ .query<{ jti: string }, [string]>(
264
+ "SELECT jti FROM tokens WHERE revoked_at IS NOT NULL AND expires_at > ? ORDER BY jti",
265
+ )
266
+ .all(now.toISOString());
267
+ return rows.map((r) => r.jti);
268
+ }
269
+
270
+ /**
271
+ * Filter for `listTokens`. `revoked` defaults to "all"; `subject` matches
272
+ * either the OAuth `user_id` or the non-OAuth `subject` column (so the
273
+ * caller doesn't need to know which mint path created the row to filter
274
+ * by identity); `createdVia` narrows by mint provenance (OAuth refresh,
275
+ * operator-token mint, CLI / api-mint-token).
276
+ */
277
+ export interface ListTokensFilter {
278
+ revoked?: "true" | "false" | "all";
279
+ subject?: string;
280
+ createdVia?: TokenCreatedVia;
281
+ }
282
+
283
+ /**
284
+ * Cursor-paginated list of `tokens` rows. Powers `GET /api/auth/tokens` and
285
+ * the future admin UI's list view.
286
+ *
287
+ * Order is `created_at DESC, jti DESC` (newest-first; jti tiebreaks for
288
+ * the rare case where two rows share a created_at, which can happen in
289
+ * tests or under burst issuance). The cursor is an opaque base64 of the
290
+ * `(created_at, jti)` composite from the previous page's last row;
291
+ * pagination resumes "strictly older than that pair." Page size is a
292
+ * hardcoded 50 — see in-function comment on the `limit` constant.
293
+ *
294
+ * Returns the page rows plus a `nextCursor` if more rows exist (we
295
+ * fetch `limit + 1` and detect the trailing row, avoiding a second
296
+ * round-trip just to ask "is there more?").
297
+ */
298
+ export interface ListTokensPage {
299
+ rows: RefreshTokenRow[];
300
+ nextCursor: string | null;
301
+ }
302
+
303
+ /** Page size. Hardcoded for now — see comment in `listTokens`. */
304
+ const LIST_TOKENS_PAGE_SIZE = 50;
305
+
306
+ export function listTokens(
307
+ db: Database,
308
+ opts: { filter?: ListTokensFilter; cursor?: string | null } = {},
309
+ ): ListTokensPage {
310
+ // Page size is intentionally a hardcoded constant rather than an opt. The
311
+ // sole consumer (admin UI list view) doesn't need configurable pagination,
312
+ // and adding the seam invites consumers to pass arbitrary values that we'd
313
+ // then have to clamp + validate. When a real second consumer surfaces
314
+ // (e.g. a CLI `auth list-tokens` command), wire `?limit=N` then.
315
+ const limit = LIST_TOKENS_PAGE_SIZE;
316
+ const filter = opts.filter ?? {};
317
+
318
+ // Cursor decode. Malformed cursors are treated as "no cursor" rather
319
+ // than 400ing — the SPA may pass a stale cursor across reloads, and a
320
+ // silent reset to page 1 is the friendliest fallback. (If we ever need
321
+ // strict cursor validation for security reasons, this is the seam.)
322
+ let cursorCreatedAt: string | undefined;
323
+ let cursorJti: string | undefined;
324
+ if (opts.cursor) {
325
+ try {
326
+ const decoded = JSON.parse(Buffer.from(opts.cursor, "base64").toString("utf8")) as {
327
+ created_at?: unknown;
328
+ jti?: unknown;
329
+ };
330
+ if (typeof decoded.created_at === "string" && typeof decoded.jti === "string") {
331
+ cursorCreatedAt = decoded.created_at;
332
+ cursorJti = decoded.jti;
333
+ }
334
+ } catch {
335
+ // ignore — silent reset to page 1
336
+ }
337
+ }
338
+
339
+ const wheres: string[] = [];
340
+ const params: (string | number)[] = [];
341
+ if (filter.revoked === "true") {
342
+ wheres.push("revoked_at IS NOT NULL");
343
+ } else if (filter.revoked === "false") {
344
+ wheres.push("revoked_at IS NULL");
345
+ }
346
+ if (typeof filter.subject === "string" && filter.subject.length > 0) {
347
+ wheres.push("(user_id = ? OR subject = ?)");
348
+ params.push(filter.subject, filter.subject);
349
+ }
350
+ if (filter.createdVia) {
351
+ wheres.push("created_via = ?");
352
+ params.push(filter.createdVia);
353
+ }
354
+ if (cursorCreatedAt !== undefined && cursorJti !== undefined) {
355
+ // "strictly older than (cursor.created_at, cursor.jti)" under the
356
+ // composite ORDER BY created_at DESC, jti DESC. SQLite supports
357
+ // tuple comparison via parens.
358
+ wheres.push("(created_at, jti) < (?, ?)");
359
+ params.push(cursorCreatedAt, cursorJti);
360
+ }
361
+
362
+ const whereSql = wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "";
363
+ const sql = `SELECT * FROM tokens ${whereSql} ORDER BY created_at DESC, jti DESC LIMIT ?`;
364
+ params.push(limit + 1); // fetch one extra to detect "more pages"
365
+
366
+ const rows = db.query<TokenRowDb, (string | number)[]>(sql).all(...params);
367
+ const hasMore = rows.length > limit;
368
+ const pageRows = (hasMore ? rows.slice(0, limit) : rows).map(rowToRefreshToken);
369
+
370
+ let nextCursor: string | null = null;
371
+ if (hasMore && pageRows.length > 0) {
372
+ const last = pageRows[pageRows.length - 1]!;
373
+ nextCursor = Buffer.from(
374
+ JSON.stringify({ created_at: last.createdAt, jti: last.jti }),
375
+ "utf8",
376
+ ).toString("base64");
377
+ }
378
+
379
+ return { rows: pageRows, nextCursor };
380
+ }
381
+
162
382
  export interface ValidatedAccessToken {
163
383
  payload: JWTPayload;
164
384
  kid: string;
@@ -209,9 +429,29 @@ export async function validateAccessToken(
209
429
  * decides what to do with `revokedAt` — the rotation path treats a revoked
210
430
  * row as theft (RFC 6819 §5.2.2.3).
211
431
  */
432
+ /**
433
+ * Generic shape of a `tokens` row, post-v6. Covers OAuth refresh tokens
434
+ * (`createdVia === "oauth_refresh"`, `userId` set, `subject` null) and
435
+ * the new non-OAuth mint paths (`createdVia === "cli_mint" |
436
+ * "operator_mint"`, may have `userId` set if minted against a hub user,
437
+ * `subject` always set to the operator/service name).
438
+ *
439
+ * `identity` is the canonical "who is this token for" — `userId ??
440
+ * subject`. Use it when you don't care about the OAuth-vs-non distinction.
441
+ */
212
442
  export interface RefreshTokenRow {
213
443
  jti: string;
214
- userId: string;
444
+ /**
445
+ * Hub user id when the row was OAuth-issued or minted against a hub
446
+ * user. Null for service-tied mints (where `subject` carries the
447
+ * service name).
448
+ */
449
+ userId: string | null;
450
+ /**
451
+ * Non-user subject: operator name, service short name, agent id. Null
452
+ * for OAuth refresh-token rows (those use `userId` directly).
453
+ */
454
+ subject: string | null;
215
455
  clientId: string;
216
456
  scopes: string[];
217
457
  /** Family identifier — shared across rotated descendants (#73). */
@@ -219,29 +459,49 @@ export interface RefreshTokenRow {
219
459
  expiresAt: string;
220
460
  revokedAt: string | null;
221
461
  createdAt: string;
462
+ /** Provenance tag — drives admin UI grouping. v6 onward this is set on every row. */
463
+ createdVia: TokenCreatedVia;
464
+ /** JSON-encoded fine-grained constraints (auth-architecture-shape.md §11.3). */
465
+ permissions: string | null;
466
+ }
467
+
468
+ /**
469
+ * Convenience: returns the canonical identity string for a tokens row,
470
+ * collapsing the OAuth-vs-non-OAuth distinction. OAuth rows return
471
+ * `userId`; CLI/operator-minted rows return `subject`. At least one is
472
+ * always set post-v6.
473
+ */
474
+ export function tokenRowIdentity(row: RefreshTokenRow): string {
475
+ return row.userId ?? row.subject ?? "";
222
476
  }
223
477
 
224
478
  interface TokenRowDb {
225
479
  jti: string;
226
- user_id: string;
480
+ user_id: string | null;
227
481
  client_id: string;
228
482
  scopes: string;
229
483
  family_id: string | null;
230
484
  expires_at: string;
231
485
  revoked_at: string | null;
232
486
  created_at: string;
487
+ permissions: string | null;
488
+ created_via: string;
489
+ subject: string | null;
233
490
  }
234
491
 
235
492
  function rowToRefreshToken(row: TokenRowDb): RefreshTokenRow {
236
493
  return {
237
494
  jti: row.jti,
238
495
  userId: row.user_id,
496
+ subject: row.subject,
239
497
  clientId: row.client_id,
240
498
  scopes: row.scopes.split(" ").filter((s) => s.length > 0),
241
499
  familyId: row.family_id ?? row.jti,
242
500
  expiresAt: row.expires_at,
243
501
  revokedAt: row.revoked_at,
244
502
  createdAt: row.created_at,
503
+ createdVia: (row.created_via as TokenCreatedVia) ?? "oauth_refresh",
504
+ permissions: row.permissions,
245
505
  };
246
506
  }
247
507