@openparachute/hub 0.5.7 → 0.5.10-rc.10
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-modules-ops.test.ts +658 -0
- package/src/__tests__/api-modules.test.ts +426 -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__/csrf.test.ts +40 -1
- 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 +584 -67
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +123 -53
- 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 +522 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/request-protocol.test.ts +54 -0
- package/src/__tests__/serve-boot.test.ts +193 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/sessions.test.ts +25 -2
- package/src/__tests__/setup-gate.test.ts +222 -0
- package/src/__tests__/setup-wizard.test.ts +2089 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +482 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +37 -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-modules-ops.ts +585 -0
- package/src/api-modules.ts +367 -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-boot.ts +133 -0
- package/src/commands/serve.ts +214 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +34 -13
- package/src/help.ts +55 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +87 -0
- package/src/hub-server.ts +767 -136
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +298 -150
- 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 +262 -56
- 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/request-protocol.ts +48 -0
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +30 -18
- package/src/setup-wizard.ts +2009 -0
- package/src/supervisor.ts +411 -0
- package/src/vault-name.ts +71 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
- package/web/ui/dist/assets/index-CP07NbdF.js +61 -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,249 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
type DetectInstallSourceDeps,
|
|
4
|
+
detectHubInstallSource,
|
|
5
|
+
detectInstallSource,
|
|
6
|
+
formatInstallSourceLabel,
|
|
7
|
+
isStale,
|
|
8
|
+
} from "../install-source.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Stub helpers for the detect path. Production reads the operator's bun
|
|
12
|
+
* globals + real package.jsons; here we wire everything from a virtual
|
|
13
|
+
* filesystem so each kind (npm / bun-linked / unknown / stale) has a
|
|
14
|
+
* deterministic shape.
|
|
15
|
+
*/
|
|
16
|
+
function makeDeps(opts: {
|
|
17
|
+
prefixes?: readonly string[];
|
|
18
|
+
packageVersions?: Record<string, string>;
|
|
19
|
+
bunGlobalLinks?: Record<string, string>;
|
|
20
|
+
gitHeads?: Record<string, string>;
|
|
21
|
+
}): DetectInstallSourceDeps {
|
|
22
|
+
const prefixes = opts.prefixes ?? ["/home/test/.bun/install/global/node_modules"];
|
|
23
|
+
return {
|
|
24
|
+
bunGlobalPrefixes: () => prefixes,
|
|
25
|
+
resolveBunGlobal: (pkg) => opts.bunGlobalLinks?.[pkg] ?? null,
|
|
26
|
+
readJson: (path) => {
|
|
27
|
+
// Path looks like `<pkgDir>/package.json` — strip suffix.
|
|
28
|
+
const pkgDirRaw = path.replace(/\/package\.json$/, "");
|
|
29
|
+
const v = opts.packageVersions?.[pkgDirRaw];
|
|
30
|
+
if (v === undefined) throw new Error(`no package.json at ${pkgDirRaw}`);
|
|
31
|
+
return { name: "@stub/pkg", version: v };
|
|
32
|
+
},
|
|
33
|
+
readGitHead: (path) => opts.gitHeads?.[path],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("detectInstallSource", () => {
|
|
38
|
+
test("classifies a bun-linked checkout (installDir outside bun globals)", () => {
|
|
39
|
+
const deps = makeDeps({
|
|
40
|
+
packageVersions: { "/Users/me/code/parachute-notes": "0.3.15-rc.1" },
|
|
41
|
+
gitHeads: { "/Users/me/code/parachute-notes": "051c404" },
|
|
42
|
+
});
|
|
43
|
+
const source = detectInstallSource(
|
|
44
|
+
{ entryName: "parachute-notes", installDir: "/Users/me/code/parachute-notes" },
|
|
45
|
+
deps,
|
|
46
|
+
);
|
|
47
|
+
expect(source.kind).toBe("bun-linked");
|
|
48
|
+
expect(source.path).toBe("/Users/me/code/parachute-notes");
|
|
49
|
+
expect(source.gitHead).toBe("051c404");
|
|
50
|
+
expect(source.livePackageVersion).toBe("0.3.15-rc.1");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("classifies an npm install (installDir under bun globals)", () => {
|
|
54
|
+
const deps = makeDeps({
|
|
55
|
+
prefixes: ["/home/test/.bun/install/global/node_modules"],
|
|
56
|
+
packageVersions: {
|
|
57
|
+
"/home/test/.bun/install/global/node_modules/@openparachute/scribe": "0.4.2-rc.1",
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const source = detectInstallSource(
|
|
61
|
+
{
|
|
62
|
+
entryName: "parachute-scribe",
|
|
63
|
+
installDir: "/home/test/.bun/install/global/node_modules/@openparachute/scribe",
|
|
64
|
+
},
|
|
65
|
+
deps,
|
|
66
|
+
);
|
|
67
|
+
expect(source.kind).toBe("npm");
|
|
68
|
+
expect(source.livePackageVersion).toBe("0.4.2-rc.1");
|
|
69
|
+
expect(source.gitHead).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("falls back to bun-global symlink lookup when installDir is absent", () => {
|
|
73
|
+
const deps = makeDeps({
|
|
74
|
+
bunGlobalLinks: { "@openparachute/vault": "/Users/me/code/parachute-vault" },
|
|
75
|
+
packageVersions: { "/Users/me/code/parachute-vault": "0.4.4-rc.3" },
|
|
76
|
+
gitHeads: { "/Users/me/code/parachute-vault": "8aa167b" },
|
|
77
|
+
});
|
|
78
|
+
const source = detectInstallSource({ entryName: "parachute-vault" }, deps);
|
|
79
|
+
expect(source.kind).toBe("bun-linked");
|
|
80
|
+
expect(source.path).toBe("/Users/me/code/parachute-vault");
|
|
81
|
+
expect(source.gitHead).toBe("8aa167b");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("returns unknown when nothing resolves (no installDir, no first-party mapping)", () => {
|
|
85
|
+
const deps = makeDeps({});
|
|
86
|
+
const source = detectInstallSource({ entryName: "agent" }, deps);
|
|
87
|
+
expect(source.kind).toBe("unknown");
|
|
88
|
+
expect(source.path).toBeUndefined();
|
|
89
|
+
expect(source.gitHead).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("omits gitHead when the bun-linked path isn't a git repo", () => {
|
|
93
|
+
const deps = makeDeps({
|
|
94
|
+
packageVersions: { "/tmp/no-git/pkg": "1.0.0" },
|
|
95
|
+
// gitHeads intentionally missing → readGitHead returns undefined.
|
|
96
|
+
});
|
|
97
|
+
const source = detectInstallSource(
|
|
98
|
+
{ entryName: "third-party", installDir: "/tmp/no-git/pkg" },
|
|
99
|
+
deps,
|
|
100
|
+
);
|
|
101
|
+
expect(source.kind).toBe("bun-linked");
|
|
102
|
+
expect(source.gitHead).toBeUndefined();
|
|
103
|
+
expect(source.livePackageVersion).toBe("1.0.0");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("omits livePackageVersion when package.json is unreadable", () => {
|
|
107
|
+
const deps = makeDeps({
|
|
108
|
+
packageVersions: {}, // every read throws
|
|
109
|
+
});
|
|
110
|
+
const source = detectInstallSource(
|
|
111
|
+
{ entryName: "third-party", installDir: "/tmp/no-pkg" },
|
|
112
|
+
deps,
|
|
113
|
+
);
|
|
114
|
+
expect(source.kind).toBe("bun-linked");
|
|
115
|
+
expect(source.livePackageVersion).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("trailing-slash prefix doesn't false-match a sibling directory", () => {
|
|
119
|
+
// Subtle: `/home/test/.bun/install/global/node_modules-other` shouldn't
|
|
120
|
+
// be classified as "under" `/home/test/.bun/install/global/node_modules`.
|
|
121
|
+
// The prefix join in `isUnderBunGlobals` adds a trailing slash precisely
|
|
122
|
+
// to avoid this — pin the behavior.
|
|
123
|
+
const deps = makeDeps({
|
|
124
|
+
prefixes: ["/home/test/.bun/install/global/node_modules"],
|
|
125
|
+
packageVersions: {
|
|
126
|
+
"/home/test/.bun/install/global/node_modules-other/pkg": "1.0.0",
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
const source = detectInstallSource(
|
|
130
|
+
{
|
|
131
|
+
entryName: "third-party",
|
|
132
|
+
installDir: "/home/test/.bun/install/global/node_modules-other/pkg",
|
|
133
|
+
},
|
|
134
|
+
deps,
|
|
135
|
+
);
|
|
136
|
+
expect(source.kind).toBe("bun-linked");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("isStale", () => {
|
|
141
|
+
test("flags drift between cached entry version and live package.json", () => {
|
|
142
|
+
expect(
|
|
143
|
+
isStale("0.3.11-rc.1", {
|
|
144
|
+
kind: "bun-linked",
|
|
145
|
+
path: "/Users/me/code/parachute-notes",
|
|
146
|
+
livePackageVersion: "0.3.15-rc.1",
|
|
147
|
+
}),
|
|
148
|
+
).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("does not flag a matching version", () => {
|
|
152
|
+
expect(
|
|
153
|
+
isStale("0.3.15-rc.1", {
|
|
154
|
+
kind: "bun-linked",
|
|
155
|
+
path: "/Users/me/code/parachute-notes",
|
|
156
|
+
livePackageVersion: "0.3.15-rc.1",
|
|
157
|
+
}),
|
|
158
|
+
).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("does not flag npm-installed services (cached version IS the source)", () => {
|
|
162
|
+
expect(
|
|
163
|
+
isStale("0.4.2-rc.1", {
|
|
164
|
+
kind: "npm",
|
|
165
|
+
path: "/path/to/global",
|
|
166
|
+
livePackageVersion: "0.4.2-rc.1",
|
|
167
|
+
}),
|
|
168
|
+
).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("does not flag when live version is unavailable", () => {
|
|
172
|
+
expect(
|
|
173
|
+
isStale("0.3.11-rc.1", {
|
|
174
|
+
kind: "bun-linked",
|
|
175
|
+
path: "/Users/me/code/parachute-notes",
|
|
176
|
+
// livePackageVersion absent — can't compute drift, don't false-flag.
|
|
177
|
+
}),
|
|
178
|
+
).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("does not flag unknown sources", () => {
|
|
182
|
+
expect(isStale("1.0.0", { kind: "unknown" })).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("formatInstallSourceLabel", () => {
|
|
187
|
+
test("bun-linked → basename + short SHA", () => {
|
|
188
|
+
expect(
|
|
189
|
+
formatInstallSourceLabel({
|
|
190
|
+
kind: "bun-linked",
|
|
191
|
+
path: "/Users/me/code/parachute-notes",
|
|
192
|
+
gitHead: "051c404",
|
|
193
|
+
}),
|
|
194
|
+
).toBe("bun-linked → parachute-notes @ 051c404");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("bun-linked without gitHead drops the @ <sha> suffix", () => {
|
|
198
|
+
expect(
|
|
199
|
+
formatInstallSourceLabel({
|
|
200
|
+
kind: "bun-linked",
|
|
201
|
+
path: "/Users/me/code/parachute-notes",
|
|
202
|
+
}),
|
|
203
|
+
).toBe("bun-linked → parachute-notes");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("npm with version", () => {
|
|
207
|
+
expect(
|
|
208
|
+
formatInstallSourceLabel({
|
|
209
|
+
kind: "npm",
|
|
210
|
+
path: "/some/global/dir",
|
|
211
|
+
livePackageVersion: "0.4.2-rc.1",
|
|
212
|
+
}),
|
|
213
|
+
).toBe("npm (0.4.2-rc.1)");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("npm without version", () => {
|
|
217
|
+
expect(formatInstallSourceLabel({ kind: "npm" })).toBe("npm");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("unknown sources render as 'unknown'", () => {
|
|
221
|
+
expect(formatInstallSourceLabel({ kind: "unknown" })).toBe("unknown");
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("detectHubInstallSource", () => {
|
|
226
|
+
test("classifies the hub based on its source location", () => {
|
|
227
|
+
// Exercise the happy path via the real hub's `src/` dir. The result
|
|
228
|
+
// depends on the test environment (CI vs. bun-linked checkout), so we
|
|
229
|
+
// only assert the kind is one of the known classifications — not the
|
|
230
|
+
// exact value. `readGitHead` is stubbed so the test never forks a real
|
|
231
|
+
// git process; the contract under test is "climb to package.json,
|
|
232
|
+
// classify by location against bun globals" — git is incidental.
|
|
233
|
+
const source = detectHubInstallSource(import.meta.dir, {
|
|
234
|
+
readGitHead: () => "deadbeef",
|
|
235
|
+
});
|
|
236
|
+
expect(["bun-linked", "npm", "unknown"]).toContain(source.kind);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("returns unknown when no package.json exists above srcDir", () => {
|
|
240
|
+
// `/private` exists on macOS but has no package.json up the chain;
|
|
241
|
+
// injected readJson always throws so the walk hits the climb-cap.
|
|
242
|
+
const source = detectHubInstallSource("/private/var/empty", {
|
|
243
|
+
readJson: () => {
|
|
244
|
+
throw new Error("no package.json");
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
expect(source.kind).toBe("unknown");
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -9,8 +9,13 @@ import {
|
|
|
9
9
|
REFRESH_TOKEN_TTL_MS,
|
|
10
10
|
RefreshTokenInsertError,
|
|
11
11
|
findRefreshToken,
|
|
12
|
+
findTokenRowByJti,
|
|
13
|
+
listActiveRevocations,
|
|
14
|
+
recordTokenMint,
|
|
15
|
+
revokeTokenByJti,
|
|
12
16
|
signAccessToken,
|
|
13
17
|
signRefreshToken,
|
|
18
|
+
tokenRowIdentity,
|
|
14
19
|
validateAccessToken,
|
|
15
20
|
} from "../jwt-sign.ts";
|
|
16
21
|
import { getActiveSigningKey, rotateSigningKey } from "../signing-keys.ts";
|
|
@@ -359,3 +364,203 @@ describe("validateAccessToken", () => {
|
|
|
359
364
|
}
|
|
360
365
|
});
|
|
361
366
|
});
|
|
367
|
+
|
|
368
|
+
// closes #212 Phase 1 — unified token registry helpers (recordTokenMint,
|
|
369
|
+
// revokeTokenByJti, listActiveRevocations) and the v6 schema shape.
|
|
370
|
+
describe("token registry (hub#212 Phase 1)", () => {
|
|
371
|
+
test("v6 schema: tokens has user_id NULLABLE + permissions/created_via/subject", () => {
|
|
372
|
+
const { db, cleanup } = makeDb();
|
|
373
|
+
try {
|
|
374
|
+
// SQLite PRAGMA table_info reports column nullability + defaults; the
|
|
375
|
+
// bun:sqlite driver maps the row shape onto our type. The columns are
|
|
376
|
+
// (cid, name, type, notnull, dflt_value, pk) per SQLite docs.
|
|
377
|
+
type ColInfo = {
|
|
378
|
+
cid: number;
|
|
379
|
+
name: string;
|
|
380
|
+
type: string;
|
|
381
|
+
notnull: number;
|
|
382
|
+
dflt_value: string | null;
|
|
383
|
+
pk: number;
|
|
384
|
+
};
|
|
385
|
+
const cols = db.query<ColInfo, []>("PRAGMA table_info(tokens)").all();
|
|
386
|
+
const byName = new Map(cols.map((c) => [c.name, c]));
|
|
387
|
+
// Pre-v6: user_id NOT NULL. Post-v6: user_id NULLABLE.
|
|
388
|
+
expect(byName.get("user_id")?.notnull).toBe(0);
|
|
389
|
+
// New columns.
|
|
390
|
+
expect(byName.has("permissions")).toBe(true);
|
|
391
|
+
expect(byName.has("created_via")).toBe(true);
|
|
392
|
+
expect(byName.has("subject")).toBe(true);
|
|
393
|
+
// created_via has the back-compat default for pre-v6 rows.
|
|
394
|
+
expect(byName.get("created_via")?.dflt_value).toMatch(/oauth_refresh/);
|
|
395
|
+
} finally {
|
|
396
|
+
cleanup();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("recordTokenMint inserts a registry row matching the inputs", () => {
|
|
401
|
+
const { db, cleanup } = makeDb();
|
|
402
|
+
try {
|
|
403
|
+
const expiresAt = new Date(Date.now() + 86400_000).toISOString();
|
|
404
|
+
recordTokenMint(db, {
|
|
405
|
+
jti: "jti-cli-1",
|
|
406
|
+
createdVia: "cli_mint",
|
|
407
|
+
subject: "operator",
|
|
408
|
+
clientId: "parachute-hub",
|
|
409
|
+
scopes: ["vault:read", "scribe:transcribe"],
|
|
410
|
+
expiresAt,
|
|
411
|
+
permissions: '{"vault":{"default":{"read_tags":["public"]}}}',
|
|
412
|
+
});
|
|
413
|
+
const row = findTokenRowByJti(db, "jti-cli-1");
|
|
414
|
+
expect(row).not.toBeNull();
|
|
415
|
+
expect(row?.userId).toBeNull();
|
|
416
|
+
expect(row?.subject).toBe("operator");
|
|
417
|
+
expect(row?.createdVia).toBe("cli_mint");
|
|
418
|
+
expect(row?.scopes).toEqual(["vault:read", "scribe:transcribe"]);
|
|
419
|
+
expect(row?.expiresAt).toBe(expiresAt);
|
|
420
|
+
expect(row?.permissions).toBe('{"vault":{"default":{"read_tags":["public"]}}}');
|
|
421
|
+
expect(row?.revokedAt).toBeNull();
|
|
422
|
+
} finally {
|
|
423
|
+
cleanup();
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("recordTokenMint with a duplicate jti throws RefreshTokenInsertError", () => {
|
|
428
|
+
const { db, cleanup } = makeDb();
|
|
429
|
+
try {
|
|
430
|
+
const expiresAt = new Date(Date.now() + 86400_000).toISOString();
|
|
431
|
+
recordTokenMint(db, {
|
|
432
|
+
jti: "jti-dup",
|
|
433
|
+
createdVia: "operator_mint",
|
|
434
|
+
subject: "operator",
|
|
435
|
+
clientId: "parachute-hub",
|
|
436
|
+
scopes: ["hub:admin"],
|
|
437
|
+
expiresAt,
|
|
438
|
+
});
|
|
439
|
+
expect(() =>
|
|
440
|
+
recordTokenMint(db, {
|
|
441
|
+
jti: "jti-dup",
|
|
442
|
+
createdVia: "cli_mint",
|
|
443
|
+
subject: "operator",
|
|
444
|
+
clientId: "parachute-hub",
|
|
445
|
+
scopes: ["vault:read"],
|
|
446
|
+
expiresAt,
|
|
447
|
+
}),
|
|
448
|
+
).toThrow(RefreshTokenInsertError);
|
|
449
|
+
} finally {
|
|
450
|
+
cleanup();
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("revokeTokenByJti flips revoked_at; second call returns false (idempotent)", () => {
|
|
455
|
+
const { db, cleanup } = makeDb();
|
|
456
|
+
try {
|
|
457
|
+
const expiresAt = new Date(Date.now() + 86400_000).toISOString();
|
|
458
|
+
recordTokenMint(db, {
|
|
459
|
+
jti: "jti-rev",
|
|
460
|
+
createdVia: "cli_mint",
|
|
461
|
+
subject: "operator",
|
|
462
|
+
clientId: "parachute-hub",
|
|
463
|
+
scopes: ["vault:read"],
|
|
464
|
+
expiresAt,
|
|
465
|
+
});
|
|
466
|
+
const now = new Date();
|
|
467
|
+
expect(revokeTokenByJti(db, "jti-rev", now)).toBe(true);
|
|
468
|
+
expect(revokeTokenByJti(db, "jti-rev", now)).toBe(false);
|
|
469
|
+
const row = findTokenRowByJti(db, "jti-rev");
|
|
470
|
+
expect(row?.revokedAt).toBe(now.toISOString());
|
|
471
|
+
} finally {
|
|
472
|
+
cleanup();
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("listActiveRevocations filters by revoked_at AND expires_at>now", () => {
|
|
477
|
+
const { db, cleanup } = makeDb();
|
|
478
|
+
try {
|
|
479
|
+
const past = new Date(Date.now() - 86400_000).toISOString();
|
|
480
|
+
const future = new Date(Date.now() + 86400_000).toISOString();
|
|
481
|
+
// Two revoked rows: one expired, one active.
|
|
482
|
+
recordTokenMint(db, {
|
|
483
|
+
jti: "jti-revoked-expired",
|
|
484
|
+
createdVia: "cli_mint",
|
|
485
|
+
subject: "operator",
|
|
486
|
+
clientId: "parachute-hub",
|
|
487
|
+
scopes: ["vault:read"],
|
|
488
|
+
expiresAt: past,
|
|
489
|
+
});
|
|
490
|
+
recordTokenMint(db, {
|
|
491
|
+
jti: "jti-revoked-active",
|
|
492
|
+
createdVia: "cli_mint",
|
|
493
|
+
subject: "operator",
|
|
494
|
+
clientId: "parachute-hub",
|
|
495
|
+
scopes: ["vault:read"],
|
|
496
|
+
expiresAt: future,
|
|
497
|
+
});
|
|
498
|
+
// One non-revoked active row (control — must NOT appear).
|
|
499
|
+
recordTokenMint(db, {
|
|
500
|
+
jti: "jti-not-revoked",
|
|
501
|
+
createdVia: "cli_mint",
|
|
502
|
+
subject: "operator",
|
|
503
|
+
clientId: "parachute-hub",
|
|
504
|
+
scopes: ["vault:read"],
|
|
505
|
+
expiresAt: future,
|
|
506
|
+
});
|
|
507
|
+
const now = new Date();
|
|
508
|
+
revokeTokenByJti(db, "jti-revoked-expired", now);
|
|
509
|
+
revokeTokenByJti(db, "jti-revoked-active", now);
|
|
510
|
+
const list = listActiveRevocations(db, now);
|
|
511
|
+
expect(list).toEqual(["jti-revoked-active"]);
|
|
512
|
+
} finally {
|
|
513
|
+
cleanup();
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("tokenRowIdentity returns userId when present, else subject", async () => {
|
|
518
|
+
const { db, cleanup } = makeDb();
|
|
519
|
+
try {
|
|
520
|
+
rotateSigningKey(db);
|
|
521
|
+
const u = await createUser(db, "owner", "pw");
|
|
522
|
+
// OAuth refresh row: userId set, subject NULL.
|
|
523
|
+
const refresh = signRefreshToken(db, {
|
|
524
|
+
jti: "jti-oauth",
|
|
525
|
+
userId: u.id,
|
|
526
|
+
clientId: "parachute-hub",
|
|
527
|
+
scopes: ["vault:read"],
|
|
528
|
+
});
|
|
529
|
+
expect(refresh.familyId).toBeDefined();
|
|
530
|
+
const oauthRow = findTokenRowByJti(db, "jti-oauth")!;
|
|
531
|
+
expect(tokenRowIdentity(oauthRow)).toBe(u.id);
|
|
532
|
+
|
|
533
|
+
// CLI mint row: userId NULL, subject set.
|
|
534
|
+
recordTokenMint(db, {
|
|
535
|
+
jti: "jti-cli",
|
|
536
|
+
createdVia: "cli_mint",
|
|
537
|
+
subject: "operator",
|
|
538
|
+
clientId: "parachute-hub",
|
|
539
|
+
scopes: ["vault:read"],
|
|
540
|
+
expiresAt: new Date(Date.now() + 86400_000).toISOString(),
|
|
541
|
+
});
|
|
542
|
+
const cliRow = findTokenRowByJti(db, "jti-cli")!;
|
|
543
|
+
expect(tokenRowIdentity(cliRow)).toBe("operator");
|
|
544
|
+
} finally {
|
|
545
|
+
cleanup();
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("signRefreshToken explicitly stamps created_via='oauth_refresh'", async () => {
|
|
550
|
+
const { db, cleanup } = makeDb();
|
|
551
|
+
try {
|
|
552
|
+
rotateSigningKey(db);
|
|
553
|
+
const u = await createUser(db, "owner", "pw");
|
|
554
|
+
signRefreshToken(db, {
|
|
555
|
+
jti: "jti-oauth-stamped",
|
|
556
|
+
userId: u.id,
|
|
557
|
+
clientId: "parachute-hub",
|
|
558
|
+
scopes: ["vault:read"],
|
|
559
|
+
});
|
|
560
|
+
const row = findTokenRowByJti(db, "jti-oauth-stamped");
|
|
561
|
+
expect(row?.createdVia).toBe("oauth_refresh");
|
|
562
|
+
} finally {
|
|
563
|
+
cleanup();
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
});
|
|
@@ -126,6 +126,54 @@ describe("validateModuleManifest", () => {
|
|
|
126
126
|
).toThrow(/http:.*https:/);
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
test("uiUrl accepts a leading-slash path (Phase D)", () => {
|
|
130
|
+
const m = validateModuleManifest({ ...VALID, uiUrl: "/notes" }, "x");
|
|
131
|
+
expect(m.uiUrl).toBe("/notes");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("uiUrl accepts an absolute https URL", () => {
|
|
135
|
+
const m = validateModuleManifest({ ...VALID, uiUrl: "https://app.example.com/" }, "x");
|
|
136
|
+
expect(m.uiUrl).toBe("https://app.example.com/");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("uiUrl rejects empty / non-string / non-url-or-path (mirrors managementUrl)", () => {
|
|
140
|
+
expect(() => validateModuleManifest({ ...VALID, uiUrl: "" }, "x")).toThrow(/uiUrl/);
|
|
141
|
+
expect(() => validateModuleManifest({ ...VALID, uiUrl: 7 }, "x")).toThrow(/uiUrl/);
|
|
142
|
+
expect(() => validateModuleManifest({ ...VALID, uiUrl: "no-slash" }, "x")).toThrow(
|
|
143
|
+
/path starting with "\/" or a full http\(s\) URL/,
|
|
144
|
+
);
|
|
145
|
+
expect(() => validateModuleManifest({ ...VALID, uiUrl: "ftp://example.com" }, "x")).toThrow(
|
|
146
|
+
/http:.*https:/,
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("uiUrl absent stays absent", () => {
|
|
151
|
+
const m = validateModuleManifest(VALID, "x");
|
|
152
|
+
expect(m.uiUrl).toBeUndefined();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Open-redirect regression: protocol-relative paths like "//evil.com" pass
|
|
156
|
+
// a naive `startsWith("/")` check but `new URL("//evil.com", base)` resolves
|
|
157
|
+
// to the foreign origin. A malicious third-party module could plant such a
|
|
158
|
+
// value in module.json:uiUrl and turn a discovery tile into an off-origin
|
|
159
|
+
// redirect. Both uiUrl and managementUrl are validated by the shared
|
|
160
|
+
// asPathOrUrl helper, so cover both.
|
|
161
|
+
test("uiUrl rejects protocol-relative paths (open-redirect regression)", () => {
|
|
162
|
+
expect(() => validateModuleManifest({ ...VALID, uiUrl: "//evil.com" }, "x")).toThrow(/uiUrl/);
|
|
163
|
+
expect(() => validateModuleManifest({ ...VALID, uiUrl: "//evil.com/path" }, "x")).toThrow(
|
|
164
|
+
/uiUrl/,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("managementUrl rejects protocol-relative paths (open-redirect regression)", () => {
|
|
169
|
+
expect(() => validateModuleManifest({ ...VALID, managementUrl: "//evil.com" }, "x")).toThrow(
|
|
170
|
+
/managementUrl/,
|
|
171
|
+
);
|
|
172
|
+
expect(() =>
|
|
173
|
+
validateModuleManifest({ ...VALID, managementUrl: "//evil.com/admin" }, "x"),
|
|
174
|
+
).toThrow(/managementUrl/);
|
|
175
|
+
});
|
|
176
|
+
|
|
129
177
|
test("managementUrl absent stays absent", () => {
|
|
130
178
|
const m = validateModuleManifest(VALID, "x");
|
|
131
179
|
expect(m.managementUrl).toBeUndefined();
|