@openparachute/hub 0.7.6 → 0.7.7-rc.13
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/README.md +7 -7
- package/package.json +8 -13
- package/src/__tests__/account-api.test.ts +798 -0
- package/src/__tests__/account-session.test.ts +252 -0
- package/src/__tests__/account-token.test.ts +316 -0
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-handlers.test.ts +25 -0
- package/src/__tests__/admin-lock.test.ts +3 -14
- package/src/__tests__/admin-module-token.test.ts +10 -30
- package/src/__tests__/admin-surfaces.test.ts +21 -0
- package/src/__tests__/api-hub-upgrade.test.ts +11 -0
- package/src/__tests__/api-mint-token.test.ts +25 -0
- package/src/__tests__/api-modules-ops.test.ts +34 -29
- package/src/__tests__/api-modules.test.ts +50 -58
- package/src/__tests__/api-revoke-token.test.ts +23 -0
- package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
- package/src/__tests__/api-tokens.test.ts +44 -0
- package/src/__tests__/audience-gate.test.ts +24 -0
- package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
- package/src/__tests__/chrome-strip.test.ts +18 -1
- package/src/__tests__/doctor.test.ts +10 -17
- package/src/__tests__/door-contract-parity.test.ts +46 -0
- package/src/__tests__/hub-command.test.ts +70 -2
- package/src/__tests__/hub-server.test.ts +322 -1
- package/src/__tests__/hub-settings.test.ts +110 -6
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +359 -5
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/notes-serve.test.ts +216 -0
- package/src/__tests__/oauth-handlers.test.ts +19 -8
- package/src/__tests__/operator-token.test.ts +1 -2
- package/src/__tests__/port-assign.test.ts +37 -17
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/__tests__/scope-explanations.test.ts +22 -2
- package/src/__tests__/serve-boot.test.ts +25 -36
- package/src/__tests__/service-spec-discovery.test.ts +30 -35
- package/src/__tests__/services-manifest.test.ts +372 -132
- package/src/__tests__/sessions.test.ts +75 -28
- package/src/__tests__/setup-wizard.test.ts +7 -10
- package/src/__tests__/setup.test.ts +13 -14
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/account-api.ts +677 -0
- package/src/account-session.ts +132 -0
- package/src/account-token.ts +200 -0
- package/src/admin-connections.ts +4 -2
- package/src/admin-lock.ts +1 -2
- package/src/admin-module-token.ts +13 -8
- package/src/admin-surfaces.ts +2 -1
- package/src/api-hub-upgrade.ts +2 -1
- package/src/api-invites.ts +19 -0
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +10 -8
- package/src/api-revoke-token.ts +2 -1
- package/src/api-settings-hub-origin.ts +2 -1
- package/src/api-settings-root-redirect.ts +97 -20
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/hub.ts +104 -0
- package/src/commands/install.ts +97 -8
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +9 -6
- package/src/help.ts +6 -6
- package/src/hub-server.ts +318 -55
- package/src/hub-settings.ts +162 -1
- package/src/hub.ts +64 -31
- package/src/invites.ts +42 -0
- package/src/module-ops-client.ts +2 -1
- package/src/notes-serve.ts +73 -31
- package/src/oauth-handlers.ts +1 -11
- package/src/operator-token.ts +0 -1
- package/src/origin-check.ts +2 -2
- package/src/root-serve.ts +156 -0
- package/src/scope-explanations.ts +35 -5
- package/src/service-spec.ts +129 -74
- package/src/services-manifest.ts +112 -52
- package/src/sessions.ts +66 -30
- package/src/surface-notes-alias.ts +126 -0
- package/src/__tests__/admin-agent-token.test.ts +0 -173
- package/src/admin-agent-token.ts +0 -147
|
@@ -18,10 +18,11 @@ import { join } from "node:path";
|
|
|
18
18
|
import {
|
|
19
19
|
API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE,
|
|
20
20
|
handleApiSettingsRootRedirect,
|
|
21
|
+
validateRootMode,
|
|
21
22
|
validateRootRedirect,
|
|
22
23
|
} from "../api-settings-root-redirect.ts";
|
|
23
24
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
24
|
-
import { getRootRedirect, setRootRedirect } from "../hub-settings.ts";
|
|
25
|
+
import { getRootMode, getRootRedirect, setRootMode, setRootRedirect } from "../hub-settings.ts";
|
|
25
26
|
import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
|
|
26
27
|
import { rotateSigningKey } from "../signing-keys.ts";
|
|
27
28
|
import { createUser } from "../users.ts";
|
|
@@ -192,7 +193,33 @@ describe("GET /api/settings/root-redirect", () => {
|
|
|
192
193
|
);
|
|
193
194
|
expect(res.status).toBe(200);
|
|
194
195
|
const body = (await res.json()) as Record<string, unknown>;
|
|
195
|
-
expect(body).toEqual({
|
|
196
|
+
expect(body).toEqual({
|
|
197
|
+
root_redirect: null,
|
|
198
|
+
resolved: "/admin",
|
|
199
|
+
source: "default",
|
|
200
|
+
root_mode: null,
|
|
201
|
+
resolved_mode: "redirect",
|
|
202
|
+
mode_source: "default",
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
207
|
+
test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
|
|
208
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
209
|
+
const res = await handleApiSettingsRootRedirect(
|
|
210
|
+
getReq({ authorization: `bearer ${bearer}` }),
|
|
211
|
+
deps(h),
|
|
212
|
+
);
|
|
213
|
+
expect(res.status).toBe(200);
|
|
214
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
215
|
+
expect(body).toEqual({
|
|
216
|
+
root_redirect: null,
|
|
217
|
+
resolved: "/admin",
|
|
218
|
+
source: "default",
|
|
219
|
+
root_mode: null,
|
|
220
|
+
resolved_mode: "redirect",
|
|
221
|
+
mode_source: "default",
|
|
222
|
+
});
|
|
196
223
|
});
|
|
197
224
|
|
|
198
225
|
test("reflects a stored value with source=db", async () => {
|
|
@@ -207,6 +234,9 @@ describe("GET /api/settings/root-redirect", () => {
|
|
|
207
234
|
root_redirect: "/surface/reading-room",
|
|
208
235
|
resolved: "/surface/reading-room",
|
|
209
236
|
source: "db",
|
|
237
|
+
root_mode: null,
|
|
238
|
+
resolved_mode: "redirect",
|
|
239
|
+
mode_source: "default",
|
|
210
240
|
});
|
|
211
241
|
});
|
|
212
242
|
|
|
@@ -221,6 +251,9 @@ describe("GET /api/settings/root-redirect", () => {
|
|
|
221
251
|
root_redirect: null,
|
|
222
252
|
resolved: "/surface/from-env",
|
|
223
253
|
source: "env",
|
|
254
|
+
root_mode: null,
|
|
255
|
+
resolved_mode: "redirect",
|
|
256
|
+
mode_source: "default",
|
|
224
257
|
});
|
|
225
258
|
});
|
|
226
259
|
});
|
|
@@ -239,7 +272,10 @@ describe("PUT /api/settings/root-redirect", () => {
|
|
|
239
272
|
deps(h),
|
|
240
273
|
);
|
|
241
274
|
expect(put.status).toBe(200);
|
|
242
|
-
expect((await put.json()) as unknown).toEqual({
|
|
275
|
+
expect((await put.json()) as unknown).toEqual({
|
|
276
|
+
root_redirect: "/surface/reading-room",
|
|
277
|
+
root_mode: null,
|
|
278
|
+
});
|
|
243
279
|
expect(getRootRedirect(h.db)).toBe("/surface/reading-room");
|
|
244
280
|
|
|
245
281
|
const get = await handleApiSettingsRootRedirect(
|
|
@@ -282,7 +318,7 @@ describe("PUT /api/settings/root-redirect", () => {
|
|
|
282
318
|
}
|
|
283
319
|
});
|
|
284
320
|
|
|
285
|
-
test("400 on a body
|
|
321
|
+
test("400 on a body with neither root_redirect nor root_mode", async () => {
|
|
286
322
|
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
287
323
|
const res = await handleApiSettingsRootRedirect(
|
|
288
324
|
putReq({ wrong: "x" }, { authorization: `Bearer ${bearer}` }),
|
|
@@ -300,3 +336,122 @@ describe("PUT /api/settings/root-redirect", () => {
|
|
|
300
336
|
expect(res.status).toBe(400);
|
|
301
337
|
});
|
|
302
338
|
});
|
|
339
|
+
|
|
340
|
+
describe("validateRootMode — pure validator", () => {
|
|
341
|
+
test("null + empty string → normalized null (clear)", () => {
|
|
342
|
+
expect(validateRootMode(null)).toEqual({ ok: true, normalized: null });
|
|
343
|
+
expect(validateRootMode("")).toEqual({ ok: true, normalized: null });
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("valid modes normalize verbatim", () => {
|
|
347
|
+
expect(validateRootMode("redirect")).toEqual({ ok: true, normalized: "redirect" });
|
|
348
|
+
expect(validateRootMode("serve-app")).toEqual({ ok: true, normalized: "serve-app" });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("rejects unknown modes + non-strings", () => {
|
|
352
|
+
for (const bad of ["serveapp", "SERVE-APP", "app", 42, {}]) {
|
|
353
|
+
expect(validateRootMode(bad).ok).toBe(false);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe("root_mode over the endpoint", () => {
|
|
359
|
+
let h: Harness;
|
|
360
|
+
beforeEach(async () => {
|
|
361
|
+
h = await makeHarness();
|
|
362
|
+
});
|
|
363
|
+
afterEach(() => h.cleanup());
|
|
364
|
+
|
|
365
|
+
test("GET reflects a stored serve-app mode with mode_source=db", async () => {
|
|
366
|
+
setRootMode(h.db, "serve-app");
|
|
367
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
368
|
+
const res = await handleApiSettingsRootRedirect(
|
|
369
|
+
getReq({ authorization: `Bearer ${bearer}` }),
|
|
370
|
+
deps(h),
|
|
371
|
+
);
|
|
372
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
373
|
+
expect(body.root_mode).toBe("serve-app");
|
|
374
|
+
expect(body.resolved_mode).toBe("serve-app");
|
|
375
|
+
expect(body.mode_source).toBe("db");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("GET surfaces an env-sourced mode while the stored row is null", async () => {
|
|
379
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
380
|
+
const res = await handleApiSettingsRootRedirect(
|
|
381
|
+
getReq({ authorization: `Bearer ${bearer}` }),
|
|
382
|
+
deps(h, { env: { PARACHUTE_HUB_ROOT_MODE: "serve-app" } }),
|
|
383
|
+
);
|
|
384
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
385
|
+
expect(body.root_mode).toBeNull();
|
|
386
|
+
expect(body.resolved_mode).toBe("serve-app");
|
|
387
|
+
expect(body.mode_source).toBe("env");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("PUT stores serve-app + GET reflects it on the next request (no restart)", async () => {
|
|
391
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
392
|
+
const put = await handleApiSettingsRootRedirect(
|
|
393
|
+
putReq({ root_mode: "serve-app" }, { authorization: `Bearer ${bearer}` }),
|
|
394
|
+
deps(h),
|
|
395
|
+
);
|
|
396
|
+
expect(put.status).toBe(200);
|
|
397
|
+
expect((await put.json()) as unknown).toEqual({ root_redirect: null, root_mode: "serve-app" });
|
|
398
|
+
expect(getRootMode(h.db)).toBe("serve-app");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("PUT redirect / null clears the row back to the default", async () => {
|
|
402
|
+
setRootMode(h.db, "serve-app");
|
|
403
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
404
|
+
// Writing the default "redirect" deletes the row (footgun-guard parity).
|
|
405
|
+
const res = await handleApiSettingsRootRedirect(
|
|
406
|
+
putReq({ root_mode: "redirect" }, { authorization: `Bearer ${bearer}` }),
|
|
407
|
+
deps(h),
|
|
408
|
+
);
|
|
409
|
+
expect(res.status).toBe(200);
|
|
410
|
+
expect(getRootMode(h.db)).toBeNull();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("PUT rejects an invalid mode with 400 + writes nothing", async () => {
|
|
414
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
415
|
+
const res = await handleApiSettingsRootRedirect(
|
|
416
|
+
putReq({ root_mode: "serveapp" }, { authorization: `Bearer ${bearer}` }),
|
|
417
|
+
deps(h),
|
|
418
|
+
);
|
|
419
|
+
expect(res.status).toBe(400);
|
|
420
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
421
|
+
expect(body.error).toBe("invalid_root_mode");
|
|
422
|
+
expect(getRootMode(h.db)).toBeNull();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("PUT applies both root_mode + root_redirect in one call", async () => {
|
|
426
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
427
|
+
const res = await handleApiSettingsRootRedirect(
|
|
428
|
+
putReq(
|
|
429
|
+
{ root_mode: "serve-app", root_redirect: "/surface/fallback" },
|
|
430
|
+
{ authorization: `Bearer ${bearer}` },
|
|
431
|
+
),
|
|
432
|
+
deps(h),
|
|
433
|
+
);
|
|
434
|
+
expect(res.status).toBe(200);
|
|
435
|
+
expect((await res.json()) as unknown).toEqual({
|
|
436
|
+
root_redirect: "/surface/fallback",
|
|
437
|
+
root_mode: "serve-app",
|
|
438
|
+
});
|
|
439
|
+
expect(getRootMode(h.db)).toBe("serve-app");
|
|
440
|
+
expect(getRootRedirect(h.db)).toBe("/surface/fallback");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("a rejected mode does NOT half-apply a valid redirect in the same call", async () => {
|
|
444
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
445
|
+
const res = await handleApiSettingsRootRedirect(
|
|
446
|
+
putReq(
|
|
447
|
+
{ root_mode: "bogus", root_redirect: "/surface/should-not-land" },
|
|
448
|
+
{ authorization: `Bearer ${bearer}` },
|
|
449
|
+
),
|
|
450
|
+
deps(h),
|
|
451
|
+
);
|
|
452
|
+
expect(res.status).toBe(400);
|
|
453
|
+
// Both-validated-before-either-applied: the redirect was NOT written.
|
|
454
|
+
expect(getRootRedirect(h.db)).toBeNull();
|
|
455
|
+
expect(getRootMode(h.db)).toBeNull();
|
|
456
|
+
});
|
|
457
|
+
});
|
|
@@ -139,6 +139,50 @@ describe("GET /api/auth/tokens (admin token list — Phase 2 backend)", () => {
|
|
|
139
139
|
}
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
143
|
+
test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
|
|
144
|
+
const h = makeHarness();
|
|
145
|
+
try {
|
|
146
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
147
|
+
try {
|
|
148
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
149
|
+
const resp = await handleApiTokens(
|
|
150
|
+
getRequest("", { authorization: `bearer ${op.token}` }),
|
|
151
|
+
{
|
|
152
|
+
db,
|
|
153
|
+
issuer: ISSUER,
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
expect(resp.status).toBe(200);
|
|
157
|
+
const body = (await resp.json()) as { tokens: unknown[] };
|
|
158
|
+
expect(body.tokens).toHaveLength(1);
|
|
159
|
+
} finally {
|
|
160
|
+
db.close();
|
|
161
|
+
}
|
|
162
|
+
} finally {
|
|
163
|
+
h.cleanup();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("mixed-case bearer scheme (BeArEr) authenticates identically to canonical Bearer", async () => {
|
|
168
|
+
const h = makeHarness();
|
|
169
|
+
try {
|
|
170
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
171
|
+
try {
|
|
172
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
173
|
+
const resp = await handleApiTokens(
|
|
174
|
+
getRequest("", { authorization: `BeArEr ${op.token}` }),
|
|
175
|
+
{ db, issuer: ISSUER },
|
|
176
|
+
);
|
|
177
|
+
expect(resp.status).toBe(200);
|
|
178
|
+
} finally {
|
|
179
|
+
db.close();
|
|
180
|
+
}
|
|
181
|
+
} finally {
|
|
182
|
+
h.cleanup();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
142
186
|
test("happy path: empty registry returns empty array", async () => {
|
|
143
187
|
const h = makeHarness();
|
|
144
188
|
try {
|
|
@@ -217,6 +217,30 @@ describe("audience gate matrix (H3)", () => {
|
|
|
217
217
|
}
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
221
|
+
test("audience: hub-users — lowercase/mixed-case bearer scheme authenticates identically to canonical Bearer", async () => {
|
|
222
|
+
const upstream = startEchoUpstream();
|
|
223
|
+
try {
|
|
224
|
+
writeServices(surfaceEntry(upstream.port, { audience: "hub-users" }));
|
|
225
|
+
const f = fetcher();
|
|
226
|
+
const bearer = await mintBearer(["vault:default:read", "vault:default:write"]);
|
|
227
|
+
|
|
228
|
+
const lower = await f(
|
|
229
|
+
req("/surface/notes/x", { headers: { authorization: `bearer ${bearer}` } }),
|
|
230
|
+
fakeServer("127.0.0.1"),
|
|
231
|
+
);
|
|
232
|
+
expect(lower?.status).toBe(200);
|
|
233
|
+
|
|
234
|
+
const mixed = await f(
|
|
235
|
+
req("/surface/notes/x", { headers: { authorization: `BeArEr ${bearer}` } }),
|
|
236
|
+
fakeServer("127.0.0.1"),
|
|
237
|
+
);
|
|
238
|
+
expect(mixed?.status).toBe(200);
|
|
239
|
+
} finally {
|
|
240
|
+
upstream.stop();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
220
244
|
test("audience: hub-users — Bearer with NON-satisfying scopes → 403; garbage Bearer → 401", async () => {
|
|
221
245
|
const upstream = startEchoUpstream();
|
|
222
246
|
try {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* H1.1 (contracts-brief H1) — Bearer scheme-casing unification.
|
|
3
|
+
*
|
|
4
|
+
* RFC 7235 §2.1: the auth-scheme token ("Bearer") is case-insensitive; the
|
|
5
|
+
* credential (the token itself) is opaque and must be passed VERBATIM — never
|
|
6
|
+
* case-folded, never trimmed beyond surrounding whitespace. This mirrors
|
|
7
|
+
* parachute-vault's V1.4 (`BEARER_PREFIX`, case-insensitive) and
|
|
8
|
+
* parachute-cloud's C1.3 (`workers/vault/src/auth.ts:130-143`) — the other two
|
|
9
|
+
* door surfaces already converged on this shape; this file (plus the per-site
|
|
10
|
+
* tests below) closes the hub's half.
|
|
11
|
+
*
|
|
12
|
+
* Every converted call site (`src/api-hub-upgrade.ts`, `src/api-tokens.ts`,
|
|
13
|
+
* `src/api-revoke-token.ts`, `src/api-settings-root-redirect.ts`,
|
|
14
|
+
* `src/api-modules.ts` ×2, `src/api-modules-ops.ts`, `src/api-mint-token.ts`,
|
|
15
|
+
* `src/api-settings-hub-origin.ts`, `src/admin-surfaces.ts`,
|
|
16
|
+
* `src/admin-connections.ts` ×2, `src/audience-gate.ts`) uses the SAME
|
|
17
|
+
* two-step shape: a case-insensitive `/^Bearer\s+/i` scheme test, followed by
|
|
18
|
+
* the PRE-EXISTING `header.slice("Bearer ".length).trim()` extraction
|
|
19
|
+
* (unchanged — "Bearer ".length === "bearer ".length === 7, so the fixed-
|
|
20
|
+
* offset slice is casing-agnostic and the token substring is untouched).
|
|
21
|
+
* `src/admin-auth.ts`'s `extractBearerToken` (the documented shared helper,
|
|
22
|
+
* used by `requireScope` + several `/admin/*` and `/api/*` routes already)
|
|
23
|
+
* uses an equivalent `/^Bearer\s+(.+)$/i` regex. This file pins that shared
|
|
24
|
+
* pattern directly, independent of any one handler's DB/deps scaffolding.
|
|
25
|
+
*
|
|
26
|
+
* Live end-to-end coverage (a lowercase/mixed-case Authorization header
|
|
27
|
+
* reaching a real handler and authenticating) lives alongside each site's
|
|
28
|
+
* existing tests, NOT here:
|
|
29
|
+
* - src/__tests__/api-hub-upgrade.test.ts
|
|
30
|
+
* - src/__tests__/api-tokens.test.ts
|
|
31
|
+
* - src/__tests__/api-revoke-token.test.ts
|
|
32
|
+
* - src/__tests__/api-settings-root-redirect.test.ts
|
|
33
|
+
* - src/__tests__/api-modules.test.ts (both handleApiModules + handleApiModulesChannel)
|
|
34
|
+
* - src/__tests__/api-modules-ops.test.ts
|
|
35
|
+
* - src/__tests__/api-mint-token.test.ts
|
|
36
|
+
* - src/__tests__/api-settings-hub-origin.test.ts
|
|
37
|
+
* - src/__tests__/admin-surfaces.test.ts
|
|
38
|
+
* - src/__tests__/admin-connections-credentials.test.ts (renew + claim)
|
|
39
|
+
* - src/__tests__/audience-gate.test.ts
|
|
40
|
+
*/
|
|
41
|
+
import { describe, expect, test } from "bun:test";
|
|
42
|
+
import { extractBearerToken } from "../admin-auth.ts";
|
|
43
|
+
|
|
44
|
+
/** The exact regex + slice shape used at all 12 converted call sites. */
|
|
45
|
+
function siteShapeExtract(header: string | null): string | null {
|
|
46
|
+
if (!header || !/^Bearer\s+/i.test(header)) return null;
|
|
47
|
+
return header.slice("Bearer ".length).trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A JWT-shaped token: base64url alphabet includes uppercase letters, so any
|
|
51
|
+
// real access/operator token already exercises "verbatim, case preserved."
|
|
52
|
+
// Use one explicitly so the pin doesn't depend on a real signer.
|
|
53
|
+
const MIXED_CASE_TOKEN = "eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJBQkNkZWYxMjMifQ.SIGNATURE-Xyz";
|
|
54
|
+
|
|
55
|
+
describe("H1.1 — site-shape extraction (regex test + fixed-offset slice)", () => {
|
|
56
|
+
test.each([
|
|
57
|
+
["Bearer", `Bearer ${MIXED_CASE_TOKEN}`],
|
|
58
|
+
["bearer", `bearer ${MIXED_CASE_TOKEN}`],
|
|
59
|
+
["BEARER", `BEARER ${MIXED_CASE_TOKEN}`],
|
|
60
|
+
["BeArEr", `BeArEr ${MIXED_CASE_TOKEN}`],
|
|
61
|
+
["bEARER", `bEARER ${MIXED_CASE_TOKEN}`],
|
|
62
|
+
])("scheme %s → token extracted verbatim, case preserved", (_label, header) => {
|
|
63
|
+
expect(siteShapeExtract(header)).toBe(MIXED_CASE_TOKEN);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("wrong scheme keyword (not Bearer) → rejected regardless of case", () => {
|
|
67
|
+
expect(siteShapeExtract(`Basic ${MIXED_CASE_TOKEN}`)).toBeNull();
|
|
68
|
+
expect(siteShapeExtract(`Digest ${MIXED_CASE_TOKEN}`)).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("missing header → rejected", () => {
|
|
72
|
+
expect(siteShapeExtract(null)).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("scheme with no token → empty string (site's own 'empty bearer token' check catches this)", () => {
|
|
76
|
+
expect(siteShapeExtract("Bearer ")).toBe("");
|
|
77
|
+
expect(siteShapeExtract("bearer ")).toBe("");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("extra internal whitespace after the scheme is tolerated (trim absorbs it), casing still ignored", () => {
|
|
81
|
+
expect(siteShapeExtract(`bearer ${MIXED_CASE_TOKEN}`)).toBe(MIXED_CASE_TOKEN);
|
|
82
|
+
expect(siteShapeExtract(`Bearer\t${MIXED_CASE_TOKEN}`)).toBe(MIXED_CASE_TOKEN);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("H1.1 — admin-auth.ts extractBearerToken (the documented shared helper)", () => {
|
|
87
|
+
function reqWith(header: string | undefined): Request {
|
|
88
|
+
return new Request("http://127.0.0.1/admin/probe", {
|
|
89
|
+
headers: header !== undefined ? { authorization: header } : {},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
test.each([
|
|
94
|
+
["Bearer", `Bearer ${MIXED_CASE_TOKEN}`],
|
|
95
|
+
["bearer", `bearer ${MIXED_CASE_TOKEN}`],
|
|
96
|
+
["BeArEr", `BeArEr ${MIXED_CASE_TOKEN}`],
|
|
97
|
+
])("scheme %s → extractBearerToken returns the token verbatim", (_label, header) => {
|
|
98
|
+
expect(extractBearerToken(reqWith(header))).toBe(MIXED_CASE_TOKEN);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("missing Authorization header → AdminAuthError(401)", () => {
|
|
102
|
+
expect(() => extractBearerToken(reqWith(undefined))).toThrow(/missing Authorization header/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("non-Bearer scheme → AdminAuthError(401)", () => {
|
|
106
|
+
expect(() => extractBearerToken(reqWith(`Basic ${MIXED_CASE_TOKEN}`))).toThrow(
|
|
107
|
+
/Authorization header must be 'Bearer <token>'/,
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -99,6 +99,20 @@ describe("shouldInjectChrome", () => {
|
|
|
99
99
|
expect(shouldInjectChrome("/surface/notes-archive/")).toBe(true);
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
test("default: opts out the app's surface mount at /surface/parachute/* (W2-12)", () => {
|
|
103
|
+
expect(shouldInjectChrome("/surface/parachute")).toBe(false);
|
|
104
|
+
expect(shouldInjectChrome("/surface/parachute/")).toBe(false);
|
|
105
|
+
expect(shouldInjectChrome("/surface/parachute/index.html")).toBe(false);
|
|
106
|
+
expect(shouldInjectChrome("/surface/parachute/assets/index-XXX.js")).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("the /surface/parachute/ opt-out does not over-match sibling paths", () => {
|
|
110
|
+
// `/surface/parachutes` must NOT match `/surface/parachute/` — the slash
|
|
111
|
+
// boundary check applies to the new entry the same as the legacy one.
|
|
112
|
+
expect(shouldInjectChrome("/surface/parachutes")).toBe(true);
|
|
113
|
+
expect(shouldInjectChrome("/surface/parachute-club/")).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
102
116
|
test("custom opt-out list is honored", () => {
|
|
103
117
|
expect(shouldInjectChrome("/foo/bar", ["/foo/"])).toBe(false);
|
|
104
118
|
expect(shouldInjectChrome("/baz", ["/foo/"])).toBe(true);
|
|
@@ -111,8 +125,11 @@ describe("shouldInjectChrome", () => {
|
|
|
111
125
|
expect(shouldInjectChrome("/foo/bar", ["/foo"])).toBe(false);
|
|
112
126
|
});
|
|
113
127
|
|
|
114
|
-
test("the canonical opt-out list contains /surface/notes/", () => {
|
|
128
|
+
test("the canonical opt-out list contains BOTH /surface/notes/ and /surface/parachute/", () => {
|
|
129
|
+
// W2-12 is additive: the legacy notes-ui mount keeps its opt-out for
|
|
130
|
+
// existing installs; the renamed app-surface mount gets its own.
|
|
115
131
|
expect(CHROME_OPT_OUT_PREFIXES).toContain("/surface/notes/");
|
|
132
|
+
expect(CHROME_OPT_OUT_PREFIXES).toContain("/surface/parachute/");
|
|
116
133
|
});
|
|
117
134
|
});
|
|
118
135
|
|
|
@@ -502,7 +502,7 @@ describe("doctor — canonical-port-drift detection (read-only)", () => {
|
|
|
502
502
|
test("a non-canonical port + a duplicate-port pair → port-drift WARNs naming the services", async () => {
|
|
503
503
|
const h = makeHarness();
|
|
504
504
|
try {
|
|
505
|
-
//
|
|
505
|
+
// Scribe drifted off 1943 onto Surface's canonical 1946 (a collision).
|
|
506
506
|
writeManifestRows(h.manifestPath, [
|
|
507
507
|
{
|
|
508
508
|
name: "parachute-vault",
|
|
@@ -511,21 +511,17 @@ describe("doctor — canonical-port-drift detection (read-only)", () => {
|
|
|
511
511
|
health: "/h",
|
|
512
512
|
version: "1",
|
|
513
513
|
},
|
|
514
|
-
{ name: "parachute-scribe", port:
|
|
515
|
-
{ name: "parachute-
|
|
514
|
+
{ name: "parachute-scribe", port: 1946, paths: ["/scribe"], health: "/h", version: "1" },
|
|
515
|
+
{ name: "parachute-surface", port: 1946, paths: ["/surface"], health: "/h", version: "1" },
|
|
516
516
|
]);
|
|
517
517
|
seedOperatorToken(h.configDir);
|
|
518
518
|
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
519
519
|
const pd = byName(checks, "port-drift");
|
|
520
520
|
expect(pd?.status).toBe("warn");
|
|
521
|
-
// Names the drifted service AND the colliding pair.
|
|
522
521
|
expect(pd?.detail).toContain("scribe");
|
|
523
|
-
expect(pd?.detail).toContain("
|
|
524
|
-
expect(pd?.detail).toContain("parachute-scribe + parachute-
|
|
522
|
+
expect(pd?.detail).toContain("1946");
|
|
523
|
+
expect(pd?.detail).toContain("parachute-scribe + parachute-surface");
|
|
525
524
|
expect(pd?.fix).toBe("parachute doctor --fix");
|
|
526
|
-
// Drift is advisory — exit stays 0 (a WARN, not a FAIL). The duplicate
|
|
527
|
-
// rows also trip modules-alive (both can't bind 1944) but that's expected
|
|
528
|
-
// for this fixture; we only assert on port-drift here.
|
|
529
525
|
expect([0, 1]).toContain(code);
|
|
530
526
|
} finally {
|
|
531
527
|
h.cleanup();
|
|
@@ -732,22 +728,19 @@ describe("doctor --fix — canonical-port repair (confirm-gated, idempotent, non
|
|
|
732
728
|
test("--fix reports a duplicate-port collision but does not auto-resolve it", async () => {
|
|
733
729
|
const h = makeHarness();
|
|
734
730
|
try {
|
|
735
|
-
// Two services collide on
|
|
736
|
-
// diff fixes the canonical drift; the collision is reported, not guessed.
|
|
731
|
+
// Two supported services collide on unassigned 1945; both are off canonical.
|
|
737
732
|
writeManifestRows(h.manifestPath, [
|
|
738
|
-
{ name: "parachute-scribe", port:
|
|
739
|
-
{ name: "parachute-
|
|
733
|
+
{ name: "parachute-scribe", port: 1945, paths: ["/scribe"], health: "/h", version: "1" },
|
|
734
|
+
{ name: "parachute-surface", port: 1945, paths: ["/surface"], health: "/h", version: "1" },
|
|
740
735
|
]);
|
|
741
736
|
const { code, lines } = await runFix(h, {}, { yes: true });
|
|
742
737
|
const text = lines.join("\n");
|
|
743
738
|
expect(text.toLowerCase()).toContain("shared by");
|
|
744
|
-
expect(text).toContain("parachute-scribe + parachute-
|
|
745
|
-
// scribe → 1943 and agent → 1941 are both off 1944, so after the rewrite
|
|
746
|
-
// they no longer collide; fix applied, exit 0.
|
|
739
|
+
expect(text).toContain("parachute-scribe + parachute-surface");
|
|
747
740
|
expect(code).toBe(0);
|
|
748
741
|
const rows = readRows(h.manifestPath);
|
|
749
742
|
expect(rows.find((r) => r.name === "parachute-scribe")?.port).toBe(1943);
|
|
750
|
-
expect(rows.find((r) => r.name === "parachute-
|
|
743
|
+
expect(rows.find((r) => r.name === "parachute-surface")?.port).toBe(1946);
|
|
751
744
|
} finally {
|
|
752
745
|
h.cleanup();
|
|
753
746
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
ACCESS_TOKEN_TTL_SECONDS as CONTRACT_ACCESS_TTL,
|
|
4
|
+
ACCOUNT_SELF_ADMIN_SCOPE as CONTRACT_ACCOUNT_ADMIN,
|
|
5
|
+
ACCOUNT_SELF_READ_SCOPE as CONTRACT_ACCOUNT_READ,
|
|
6
|
+
REFRESH_GRACE_MS as CONTRACT_REFRESH_GRACE,
|
|
7
|
+
REFRESH_TOKEN_TTL_MS as CONTRACT_REFRESH_TTL,
|
|
8
|
+
hasAccountScope,
|
|
9
|
+
} from "@openparachute/door-contract";
|
|
10
|
+
import { ACCESS_TOKEN_TTL_SECONDS, REFRESH_GRACE_MS, REFRESH_TOKEN_TTL_MS } from "../jwt-sign.ts";
|
|
11
|
+
import { ACCOUNT_SELF_ADMIN_SCOPE, ACCOUNT_SELF_READ_SCOPE } from "../scope-explanations.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Drift detector for the shared door contract (Cloud+Hub shared-core campaign,
|
|
15
|
+
* parachute-cloud#116, Phase A). The hub's issuer constants + account-scope
|
|
16
|
+
* strings are duplicated in `@openparachute/door-contract` (the hosted cloud
|
|
17
|
+
* door duplicates them too). These assertions bind the hub's LIVE runtime values
|
|
18
|
+
* to the shared canon — no runtime code path is changed by this test, but any
|
|
19
|
+
* future divergence between the hub's issuer and the shared contract fails here,
|
|
20
|
+
* forcing the change through the shared package (and thus the cloud twin).
|
|
21
|
+
*
|
|
22
|
+
* When the hub adopts the contract at runtime (Phase B — `jwt-sign.ts` imports
|
|
23
|
+
* these constants instead of re-declaring them), these become identity checks;
|
|
24
|
+
* until then they are the guardrail.
|
|
25
|
+
*/
|
|
26
|
+
describe("door-contract parity — token constants", () => {
|
|
27
|
+
test("the hub's issuer TTL/grace equal the shared contract", () => {
|
|
28
|
+
expect(ACCESS_TOKEN_TTL_SECONDS).toBe(CONTRACT_ACCESS_TTL);
|
|
29
|
+
expect(REFRESH_TOKEN_TTL_MS).toBe(CONTRACT_REFRESH_TTL);
|
|
30
|
+
expect(REFRESH_GRACE_MS).toBe(CONTRACT_REFRESH_GRACE);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("door-contract parity — account scopes", () => {
|
|
35
|
+
test("the hub's account scope strings equal the shared contract", () => {
|
|
36
|
+
expect(ACCOUNT_SELF_ADMIN_SCOPE).toBe(CONTRACT_ACCOUNT_ADMIN);
|
|
37
|
+
expect(ACCOUNT_SELF_READ_SCOPE).toBe(CONTRACT_ACCOUNT_READ);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("the shared checker agrees with the hub's admin⊇read intent", () => {
|
|
41
|
+
// The hub's `/account/*` gate treats `account:self:admin` as satisfying a
|
|
42
|
+
// read requirement (scope-explanations.ts). The shared checker must too.
|
|
43
|
+
expect(hasAccountScope([ACCOUNT_SELF_ADMIN_SCOPE], "self", "read")).toBe(true);
|
|
44
|
+
expect(hasAccountScope([ACCOUNT_SELF_READ_SCOPE], "self", "admin")).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -12,9 +12,15 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
12
12
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
hub,
|
|
17
|
+
hubSetOrigin,
|
|
18
|
+
hubSetRootMode,
|
|
19
|
+
hubSetRootRedirect,
|
|
20
|
+
rewriteCaddyfileHost,
|
|
21
|
+
} from "../commands/hub.ts";
|
|
16
22
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
17
|
-
import { getHubOrigin, getRootRedirect } from "../hub-settings.ts";
|
|
23
|
+
import { getHubOrigin, getRootMode, getRootRedirect } from "../hub-settings.ts";
|
|
18
24
|
import type { CommandResult } from "../tailscale/run.ts";
|
|
19
25
|
|
|
20
26
|
describe("parachute hub set-origin", () => {
|
|
@@ -498,3 +504,65 @@ describe("parachute hub set-root-redirect", () => {
|
|
|
498
504
|
expect(persisted()).toBe("/surface/via-dispatcher");
|
|
499
505
|
});
|
|
500
506
|
});
|
|
507
|
+
|
|
508
|
+
describe("parachute hub set-root-mode", () => {
|
|
509
|
+
let dir: string;
|
|
510
|
+
let log: string[];
|
|
511
|
+
const collect = (line: string) => log.push(line);
|
|
512
|
+
|
|
513
|
+
beforeEach(() => {
|
|
514
|
+
dir = mkdtempSync(join(tmpdir(), "hub-set-root-mode-"));
|
|
515
|
+
log = [];
|
|
516
|
+
});
|
|
517
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
518
|
+
|
|
519
|
+
function persisted(): string | null {
|
|
520
|
+
const db = openHubDb(hubDbPath(dir));
|
|
521
|
+
try {
|
|
522
|
+
return getRootMode(db);
|
|
523
|
+
} finally {
|
|
524
|
+
db.close();
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
test("persists serve-app to hub_settings.root_mode", async () => {
|
|
529
|
+
const code = await hubSetRootMode(["serve-app"], { configDir: dir, log: collect });
|
|
530
|
+
expect(code).toBe(0);
|
|
531
|
+
expect(persisted()).toBe("serve-app");
|
|
532
|
+
expect(log.join("\n")).toMatch(/serves the Parachute app/);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("`redirect` stores as absence (the default), clearing any row", async () => {
|
|
536
|
+
await hubSetRootMode(["serve-app"], { configDir: dir, log: collect });
|
|
537
|
+
const code = await hubSetRootMode(["redirect"], { configDir: dir, log: collect });
|
|
538
|
+
expect(code).toBe(0);
|
|
539
|
+
expect(persisted()).toBeNull();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("--clear deletes the row", async () => {
|
|
543
|
+
await hubSetRootMode(["serve-app"], { configDir: dir, log: collect });
|
|
544
|
+
const code = await hubSetRootMode(["--clear"], { configDir: dir, log: collect });
|
|
545
|
+
expect(code).toBe(0);
|
|
546
|
+
expect(persisted()).toBeNull();
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("rejects an unknown mode without writing", async () => {
|
|
550
|
+
for (const bad of ["serveapp", "SERVE-APP", "app", "/surface/x"]) {
|
|
551
|
+
const code = await hubSetRootMode([bad], { configDir: dir, log: collect });
|
|
552
|
+
expect(code).toBe(1);
|
|
553
|
+
expect(persisted()).toBeNull();
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("usage error (exit 1) when no mode + no --clear", async () => {
|
|
558
|
+
const code = await hubSetRootMode([], { configDir: dir, log: collect });
|
|
559
|
+
expect(code).toBe(1);
|
|
560
|
+
expect(persisted()).toBeNull();
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("routed through the `hub` dispatcher", async () => {
|
|
564
|
+
const code = await hub(["set-root-mode", "serve-app"], { configDir: dir, log: collect });
|
|
565
|
+
expect(code).toBe(0);
|
|
566
|
+
expect(persisted()).toBe("serve-app");
|
|
567
|
+
});
|
|
568
|
+
});
|