@openparachute/hub 0.7.7-rc.9 → 0.7.7
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 +4 -12
- package/src/__tests__/account-api.test.ts +29 -2
- package/src/__tests__/account-session.test.ts +9 -1
- package/src/__tests__/account-token.test.ts +26 -2
- package/src/__tests__/admin-connections-credentials.test.ts +41 -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__/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 +210 -38
- package/src/__tests__/migrate.test.ts +3 -1
- 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 +17 -26
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/__tests__/scope-explanations.test.ts +0 -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 +22 -92
- package/src/__tests__/setup-wizard.test.ts +301 -22
- package/src/__tests__/setup.test.ts +8 -13
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/__tests__/wizard-transcription.test.ts +35 -0
- package/src/__tests__/wizard.test.ts +79 -0
- package/src/account-api.ts +6 -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-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +9 -7
- 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 +72 -27
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +0 -2
- package/src/commands/wizard-transcription.ts +24 -0
- package/src/commands/wizard.ts +34 -1
- package/src/help.ts +0 -1
- package/src/hub-server.ts +111 -54
- package/src/hub-settings.ts +147 -0
- package/src/hub.ts +64 -31
- package/src/module-ops-client.ts +2 -1
- 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 +2 -5
- package/src/service-spec.ts +18 -47
- package/src/services-manifest.ts +9 -42
- package/src/setup-wizard.ts +144 -31
- 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
|
@@ -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
|
}
|
|
@@ -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
|
+
});
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
resolveClientIp,
|
|
18
18
|
stripHopByHopHeaders,
|
|
19
19
|
} from "../hub-server.ts";
|
|
20
|
-
import { setNotesRedirectDisabled } from "../hub-settings.ts";
|
|
20
|
+
import { setNotesRedirectDisabled, setRootMode } from "../hub-settings.ts";
|
|
21
21
|
import { clearNotesRedirectLogState } from "../notes-redirect.ts";
|
|
22
22
|
import { mintOperatorToken } from "../operator-token.ts";
|
|
23
23
|
import { pidPath } from "../process-state.ts";
|
|
@@ -993,6 +993,29 @@ describe("hubFetch routing", () => {
|
|
|
993
993
|
}
|
|
994
994
|
});
|
|
995
995
|
|
|
996
|
+
test("retired Agent token endpoints neither mint nor redirect", async () => {
|
|
997
|
+
const h = makeHarness();
|
|
998
|
+
try {
|
|
999
|
+
const dist = join(h.dir, "dist");
|
|
1000
|
+
mkdirIfMissing(dist);
|
|
1001
|
+
writeFileSync(join(dist, "index.html"), "<!doctype html><title>admin shell</title>");
|
|
1002
|
+
const fetcher = hubFetch(h.dir, { spaDistDir: dist });
|
|
1003
|
+
|
|
1004
|
+
for (const path of ["/admin/agent-token", "/admin/channel-token"]) {
|
|
1005
|
+
const post = await fetcher(req(path, { method: "POST" }));
|
|
1006
|
+
expect(post.status).toBe(405);
|
|
1007
|
+
expect(post.headers.get("location")).toBeNull();
|
|
1008
|
+
|
|
1009
|
+
const get = await fetcher(req(path));
|
|
1010
|
+
expect(get.status).toBe(200);
|
|
1011
|
+
expect(get.headers.get("location")).toBeNull();
|
|
1012
|
+
expect(await get.text()).toContain("admin shell");
|
|
1013
|
+
}
|
|
1014
|
+
} finally {
|
|
1015
|
+
h.cleanup();
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
|
|
996
1019
|
// 301 back-compat redirects (closes hub#231): pre-rename SPA URLs
|
|
997
1020
|
// 301-redirect to the new /admin/* mount. Tests cover every entry in the
|
|
998
1021
|
// dispatch — operator bookmarks landing on any of these still work.
|
|
@@ -1249,6 +1272,20 @@ describe("hubFetch routing", () => {
|
|
|
1249
1272
|
}
|
|
1250
1273
|
});
|
|
1251
1274
|
|
|
1275
|
+
test("retired /channel/* paths no longer redirect to /agent/*", async () => {
|
|
1276
|
+
const h = makeHarness();
|
|
1277
|
+
try {
|
|
1278
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
1279
|
+
const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
|
|
1280
|
+
req("/channel/mcp/default?q=1"),
|
|
1281
|
+
);
|
|
1282
|
+
expect(res.status).toBe(404);
|
|
1283
|
+
expect(res.headers.get("location")).toBeNull();
|
|
1284
|
+
} finally {
|
|
1285
|
+
h.cleanup();
|
|
1286
|
+
}
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1252
1289
|
test("/hub/<unknown> (no SPA mount anymore) → 404", async () => {
|
|
1253
1290
|
const h = makeHarness();
|
|
1254
1291
|
try {
|
|
@@ -6381,3 +6418,287 @@ describe("hubFetch proxied-page security headers (hub#643 Tier-1)", () => {
|
|
|
6381
6418
|
}
|
|
6382
6419
|
});
|
|
6383
6420
|
});
|
|
6421
|
+
|
|
6422
|
+
// root_mode = serve-app: the hub serves the installed app's dist AT `/`, with
|
|
6423
|
+
// the fresh-hub funnel + pre-admin 503 + every hub-owned route preempting it.
|
|
6424
|
+
describe("hubFetch — root_mode serve-app", () => {
|
|
6425
|
+
// A fixture app dist (index.html + assets). Distinct marker text so a test can
|
|
6426
|
+
// tell an app-served body from an admin-SPA / discovery body.
|
|
6427
|
+
function makeAppDist(dir: string): string {
|
|
6428
|
+
const dist = join(dir, "app-dist");
|
|
6429
|
+
mkdirSync(join(dist, "assets"), { recursive: true });
|
|
6430
|
+
writeFileSync(join(dist, "index.html"), "<!doctype html><title>PARACHUTE-APP-SHELL</title>");
|
|
6431
|
+
writeFileSync(join(dist, "assets", "app-xyz.js"), "console.log('serve-app')");
|
|
6432
|
+
writeFileSync(join(dist, "manifest.webmanifest"), '{"name":"Parachute"}');
|
|
6433
|
+
return dist;
|
|
6434
|
+
}
|
|
6435
|
+
|
|
6436
|
+
// A distinct admin-SPA fixture so `/admin` reachability is provable (its body
|
|
6437
|
+
// must NOT be the app shell).
|
|
6438
|
+
function makeAdminDist(dir: string): string {
|
|
6439
|
+
const dist = join(dir, "admin-dist");
|
|
6440
|
+
mkdirSync(dist, { recursive: true });
|
|
6441
|
+
writeFileSync(join(dist, "index.html"), "<!doctype html><title>ADMIN-SPA</title>");
|
|
6442
|
+
return dist;
|
|
6443
|
+
}
|
|
6444
|
+
|
|
6445
|
+
// Seed admin + a vault so the fresh-hub wizard funnel is bypassed (the
|
|
6446
|
+
// "setup complete" state where serve-app should actually engage).
|
|
6447
|
+
async function setupCompleteDb(h: Harness): Promise<ReturnType<typeof openHubDb>> {
|
|
6448
|
+
writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
|
|
6449
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
6450
|
+
await createUser(db, "owner", "pw");
|
|
6451
|
+
return db;
|
|
6452
|
+
}
|
|
6453
|
+
|
|
6454
|
+
test("serves the app index.html at `/` (200, text/html, no redirect, no chrome)", async () => {
|
|
6455
|
+
const h = makeHarness();
|
|
6456
|
+
const db = await setupCompleteDb(h);
|
|
6457
|
+
try {
|
|
6458
|
+
setRootMode(db, "serve-app");
|
|
6459
|
+
const dist = makeAppDist(h.dir);
|
|
6460
|
+
const res = await hubFetch(h.dir, {
|
|
6461
|
+
getDb: () => db,
|
|
6462
|
+
manifestPath: h.manifestPath,
|
|
6463
|
+
resolveAppDist: () => dist,
|
|
6464
|
+
})(req("/"));
|
|
6465
|
+
expect(res.status).toBe(200);
|
|
6466
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
6467
|
+
const body = await res.text();
|
|
6468
|
+
expect(body).toContain("PARACHUTE-APP-SHELL");
|
|
6469
|
+
// No identity chrome injected on the root-served app HTML.
|
|
6470
|
+
expect(body).not.toContain('class="auth-indicator"');
|
|
6471
|
+
} finally {
|
|
6472
|
+
db.close();
|
|
6473
|
+
h.cleanup();
|
|
6474
|
+
}
|
|
6475
|
+
});
|
|
6476
|
+
|
|
6477
|
+
test("serves an app asset from dist (/assets/*) with an inferred content-type", async () => {
|
|
6478
|
+
const h = makeHarness();
|
|
6479
|
+
const db = await setupCompleteDb(h);
|
|
6480
|
+
try {
|
|
6481
|
+
setRootMode(db, "serve-app");
|
|
6482
|
+
const dist = makeAppDist(h.dir);
|
|
6483
|
+
const res = await hubFetch(h.dir, {
|
|
6484
|
+
getDb: () => db,
|
|
6485
|
+
manifestPath: h.manifestPath,
|
|
6486
|
+
resolveAppDist: () => dist,
|
|
6487
|
+
})(req("/assets/app-xyz.js", { headers: { accept: "*/*" } }));
|
|
6488
|
+
expect(res.status).toBe(200);
|
|
6489
|
+
expect(res.headers.get("content-type") ?? "").toMatch(/javascript/);
|
|
6490
|
+
expect(await res.text()).toContain("serve-app");
|
|
6491
|
+
} finally {
|
|
6492
|
+
db.close();
|
|
6493
|
+
h.cleanup();
|
|
6494
|
+
}
|
|
6495
|
+
});
|
|
6496
|
+
|
|
6497
|
+
test("SPA-fallback: an unclaimed HTML deep link gets the app shell", async () => {
|
|
6498
|
+
const h = makeHarness();
|
|
6499
|
+
const db = await setupCompleteDb(h);
|
|
6500
|
+
try {
|
|
6501
|
+
setRootMode(db, "serve-app");
|
|
6502
|
+
const dist = makeAppDist(h.dir);
|
|
6503
|
+
const res = await hubFetch(h.dir, {
|
|
6504
|
+
getDb: () => db,
|
|
6505
|
+
manifestPath: h.manifestPath,
|
|
6506
|
+
resolveAppDist: () => dist,
|
|
6507
|
+
})(req("/some/app/deep/link", { headers: { accept: "text/html" } }));
|
|
6508
|
+
expect(res.status).toBe(200);
|
|
6509
|
+
expect(await res.text()).toContain("PARACHUTE-APP-SHELL");
|
|
6510
|
+
} finally {
|
|
6511
|
+
db.close();
|
|
6512
|
+
h.cleanup();
|
|
6513
|
+
}
|
|
6514
|
+
});
|
|
6515
|
+
|
|
6516
|
+
test("a non-HTML unclaimed path keeps the branded 404 (not the app shell)", async () => {
|
|
6517
|
+
const h = makeHarness();
|
|
6518
|
+
const db = await setupCompleteDb(h);
|
|
6519
|
+
try {
|
|
6520
|
+
setRootMode(db, "serve-app");
|
|
6521
|
+
const dist = makeAppDist(h.dir);
|
|
6522
|
+
const res = await hubFetch(h.dir, {
|
|
6523
|
+
getDb: () => db,
|
|
6524
|
+
manifestPath: h.manifestPath,
|
|
6525
|
+
resolveAppDist: () => dist,
|
|
6526
|
+
})(req("/definitely/not/a/file.json", { headers: { accept: "application/json" } }));
|
|
6527
|
+
expect(res.status).toBe(404);
|
|
6528
|
+
} finally {
|
|
6529
|
+
db.close();
|
|
6530
|
+
h.cleanup();
|
|
6531
|
+
}
|
|
6532
|
+
});
|
|
6533
|
+
|
|
6534
|
+
test("a malformed percent-encoded asset path 404s (not 500) through the real dispatch", async () => {
|
|
6535
|
+
const h = makeHarness();
|
|
6536
|
+
const db = await setupCompleteDb(h);
|
|
6537
|
+
try {
|
|
6538
|
+
setRootMode(db, "serve-app");
|
|
6539
|
+
const dist = makeAppDist(h.dir);
|
|
6540
|
+
// `%ZZ` is an invalid escape — decodeURIComponent throws. Without the
|
|
6541
|
+
// guard in serveAppAtRoot this escapes to the dispatch outer catch → 500.
|
|
6542
|
+
const res = await hubFetch(h.dir, {
|
|
6543
|
+
getDb: () => db,
|
|
6544
|
+
manifestPath: h.manifestPath,
|
|
6545
|
+
resolveAppDist: () => dist,
|
|
6546
|
+
})(req("/assets/%ZZ", { headers: { accept: "text/html" } }));
|
|
6547
|
+
expect(res.status).toBe(404);
|
|
6548
|
+
} finally {
|
|
6549
|
+
db.close();
|
|
6550
|
+
h.cleanup();
|
|
6551
|
+
}
|
|
6552
|
+
});
|
|
6553
|
+
|
|
6554
|
+
test("app-not-installed falls back to the redirect (with resolveAppDist → null)", async () => {
|
|
6555
|
+
const h = makeHarness();
|
|
6556
|
+
const db = await setupCompleteDb(h);
|
|
6557
|
+
try {
|
|
6558
|
+
setRootMode(db, "serve-app");
|
|
6559
|
+
const res = await hubFetch(h.dir, {
|
|
6560
|
+
getDb: () => db,
|
|
6561
|
+
manifestPath: h.manifestPath,
|
|
6562
|
+
resolveAppDist: () => null, // app not installed
|
|
6563
|
+
})(req("/"));
|
|
6564
|
+
// Falls through to the redirect (default /admin).
|
|
6565
|
+
expect(res.status).toBe(302);
|
|
6566
|
+
expect(res.headers.get("location")).toBe("/admin");
|
|
6567
|
+
} finally {
|
|
6568
|
+
db.close();
|
|
6569
|
+
h.cleanup();
|
|
6570
|
+
}
|
|
6571
|
+
});
|
|
6572
|
+
|
|
6573
|
+
// --- Precedence: the funnels + hub-owned routes preempt serve-app ---------
|
|
6574
|
+
|
|
6575
|
+
test("fresh-hub wizard funnel WINS over serve-app (no admin → `/` 302 /admin/setup)", async () => {
|
|
6576
|
+
const h = makeHarness();
|
|
6577
|
+
// NO admin user, NO vault → needsWizard. serve-app is set + app resolvable.
|
|
6578
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
6579
|
+
try {
|
|
6580
|
+
setRootMode(db, "serve-app");
|
|
6581
|
+
const dist = makeAppDist(h.dir);
|
|
6582
|
+
const res = await hubFetch(h.dir, {
|
|
6583
|
+
getDb: () => db,
|
|
6584
|
+
manifestPath: h.manifestPath,
|
|
6585
|
+
resolveAppDist: () => dist,
|
|
6586
|
+
})(req("/"));
|
|
6587
|
+
expect(res.status).toBe(302);
|
|
6588
|
+
expect(res.headers.get("location")).toBe("/admin/setup");
|
|
6589
|
+
} finally {
|
|
6590
|
+
db.close();
|
|
6591
|
+
h.cleanup();
|
|
6592
|
+
}
|
|
6593
|
+
});
|
|
6594
|
+
|
|
6595
|
+
test("pre-admin 503 lockout WINS over serve-app (no admin → /api/* 503 setup_required)", async () => {
|
|
6596
|
+
const h = makeHarness();
|
|
6597
|
+
const db = openHubDb(hubDbPath(h.dir)); // no admin
|
|
6598
|
+
try {
|
|
6599
|
+
setRootMode(db, "serve-app");
|
|
6600
|
+
const dist = makeAppDist(h.dir);
|
|
6601
|
+
const res = await hubFetch(h.dir, {
|
|
6602
|
+
getDb: () => db,
|
|
6603
|
+
manifestPath: h.manifestPath,
|
|
6604
|
+
resolveAppDist: () => dist,
|
|
6605
|
+
})(req("/api/hub"));
|
|
6606
|
+
expect(res.status).toBe(503);
|
|
6607
|
+
const body = (await res.json()) as { error?: string };
|
|
6608
|
+
expect(body.error).toBe("setup_required");
|
|
6609
|
+
} finally {
|
|
6610
|
+
db.close();
|
|
6611
|
+
h.cleanup();
|
|
6612
|
+
}
|
|
6613
|
+
});
|
|
6614
|
+
|
|
6615
|
+
test("/admin stays reachable in serve-app mode (dispatched before the tail, not the app shell)", async () => {
|
|
6616
|
+
const h = makeHarness();
|
|
6617
|
+
const db = await setupCompleteDb(h);
|
|
6618
|
+
try {
|
|
6619
|
+
setRootMode(db, "serve-app");
|
|
6620
|
+
const appDist = makeAppDist(h.dir);
|
|
6621
|
+
const adminDist = makeAdminDist(h.dir);
|
|
6622
|
+
const res = await hubFetch(h.dir, {
|
|
6623
|
+
getDb: () => db,
|
|
6624
|
+
manifestPath: h.manifestPath,
|
|
6625
|
+
spaDistDir: adminDist,
|
|
6626
|
+
resolveAppDist: () => appDist,
|
|
6627
|
+
})(req("/admin"));
|
|
6628
|
+
expect(res.status).toBe(200);
|
|
6629
|
+
const body = await res.text();
|
|
6630
|
+
expect(body).toContain("ADMIN-SPA");
|
|
6631
|
+
expect(body).not.toContain("PARACHUTE-APP-SHELL");
|
|
6632
|
+
} finally {
|
|
6633
|
+
db.close();
|
|
6634
|
+
h.cleanup();
|
|
6635
|
+
}
|
|
6636
|
+
});
|
|
6637
|
+
|
|
6638
|
+
test("reserved /api|/oauth|/.well-known unclaimed paths keep the branded 404 (not the app shell)", async () => {
|
|
6639
|
+
const h = makeHarness();
|
|
6640
|
+
const db = await setupCompleteDb(h);
|
|
6641
|
+
try {
|
|
6642
|
+
setRootMode(db, "serve-app");
|
|
6643
|
+
const dist = makeAppDist(h.dir);
|
|
6644
|
+
const handler = hubFetch(h.dir, {
|
|
6645
|
+
getDb: () => db,
|
|
6646
|
+
manifestPath: h.manifestPath,
|
|
6647
|
+
resolveAppDist: () => dist,
|
|
6648
|
+
});
|
|
6649
|
+
// A browser navigation (Accept: text/html) to an unmatched /api path must
|
|
6650
|
+
// NOT be shelled — it keeps the branded 404 for the API namespace.
|
|
6651
|
+
const res = await handler(req("/api/nonexistent", { headers: { accept: "text/html" } }));
|
|
6652
|
+
expect(res.status).toBe(404);
|
|
6653
|
+
expect(await res.text()).not.toContain("PARACHUTE-APP-SHELL");
|
|
6654
|
+
} finally {
|
|
6655
|
+
db.close();
|
|
6656
|
+
h.cleanup();
|
|
6657
|
+
}
|
|
6658
|
+
});
|
|
6659
|
+
|
|
6660
|
+
// --- Regression pin: redirect mode is byte-identical to before ------------
|
|
6661
|
+
|
|
6662
|
+
test("redirect mode (default) is inert even with resolveAppDist wired — `/` still 302s /admin", async () => {
|
|
6663
|
+
const h = makeHarness();
|
|
6664
|
+
const db = await setupCompleteDb(h); // no root_mode set → default redirect
|
|
6665
|
+
try {
|
|
6666
|
+
const dist = makeAppDist(h.dir);
|
|
6667
|
+
const handler = hubFetch(h.dir, {
|
|
6668
|
+
getDb: () => db,
|
|
6669
|
+
manifestPath: h.manifestPath,
|
|
6670
|
+
resolveAppDist: () => dist, // wired but must stay inert in redirect mode
|
|
6671
|
+
});
|
|
6672
|
+
const rootRes = await handler(req("/"));
|
|
6673
|
+
expect(rootRes.status).toBe(302);
|
|
6674
|
+
expect(rootRes.headers.get("location")).toBe("/admin");
|
|
6675
|
+
// The 404 tail is unchanged too: an unclaimed HTML path is the branded
|
|
6676
|
+
// 404 page, NOT the app shell.
|
|
6677
|
+
const tailRes = await handler(req("/random/unclaimed", { headers: { accept: "text/html" } }));
|
|
6678
|
+
expect(tailRes.status).toBe(404);
|
|
6679
|
+
expect(await tailRes.text()).not.toContain("PARACHUTE-APP-SHELL");
|
|
6680
|
+
} finally {
|
|
6681
|
+
db.close();
|
|
6682
|
+
h.cleanup();
|
|
6683
|
+
}
|
|
6684
|
+
});
|
|
6685
|
+
|
|
6686
|
+
test("a non-GET `/` in serve-app mode keeps its 302 (only GET serves the app)", async () => {
|
|
6687
|
+
const h = makeHarness();
|
|
6688
|
+
const db = await setupCompleteDb(h);
|
|
6689
|
+
try {
|
|
6690
|
+
setRootMode(db, "serve-app");
|
|
6691
|
+
const dist = makeAppDist(h.dir);
|
|
6692
|
+
const res = await hubFetch(h.dir, {
|
|
6693
|
+
getDb: () => db,
|
|
6694
|
+
manifestPath: h.manifestPath,
|
|
6695
|
+
resolveAppDist: () => dist,
|
|
6696
|
+
})(req("/", { method: "POST" }));
|
|
6697
|
+
expect(res.status).toBe(302);
|
|
6698
|
+
expect(res.headers.get("location")).toBe("/admin");
|
|
6699
|
+
} finally {
|
|
6700
|
+
db.close();
|
|
6701
|
+
h.cleanup();
|
|
6702
|
+
}
|
|
6703
|
+
});
|
|
6704
|
+
});
|