@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.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +526 -67
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +375 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/setup-gate.test.ts +196 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +408 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +32 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve.ts +157 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +6 -3
- package/src/help.ts +54 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +630 -135
- package/src/hub.ts +272 -149
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +238 -54
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +1 -1
- package/src/supervisor.ts +359 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
- package/web/ui/dist/assets/index-D54otIhv.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- 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, ~
|
|
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
|
-
|
|
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
|
|