@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
|
@@ -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
|
+
});
|
|
@@ -12,30 +12,38 @@ import { join } from "node:path";
|
|
|
12
12
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
13
13
|
import {
|
|
14
14
|
DEFAULT_MODULE_INSTALL_CHANNEL,
|
|
15
|
+
DEFAULT_ROOT_MODE,
|
|
15
16
|
DEFAULT_ROOT_REDIRECT,
|
|
16
17
|
FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
|
|
17
18
|
MODULE_INSTALL_CHANNELS,
|
|
19
|
+
PARACHUTE_HUB_ROOT_MODE_ENV,
|
|
18
20
|
PARACHUTE_HUB_ROOT_REDIRECT_ENV,
|
|
19
21
|
PARACHUTE_INSTALL_CHANNEL_ENV,
|
|
20
22
|
PARACHUTE_MODULE_CHANNEL_ENV,
|
|
23
|
+
ROOT_MODES,
|
|
21
24
|
SETUP_EXPOSE_MODES,
|
|
22
25
|
consumeFirstClientAutoApproveWindow,
|
|
23
26
|
deleteSetting,
|
|
24
27
|
getHubOrigin,
|
|
25
28
|
getModuleInstallChannel,
|
|
29
|
+
getRootMode,
|
|
26
30
|
getRootRedirect,
|
|
27
31
|
getSetting,
|
|
28
32
|
isFirstClientAutoApproveWindowOpen,
|
|
29
33
|
isModuleInstallChannel,
|
|
30
34
|
isNotesRedirectDisabled,
|
|
35
|
+
isRootMode,
|
|
31
36
|
isSafeRedirectPath,
|
|
32
37
|
isSetupExposeMode,
|
|
33
38
|
openFirstClientAutoApproveWindow,
|
|
39
|
+
resolveRootMode,
|
|
40
|
+
resolveRootModeDetailed,
|
|
34
41
|
resolveRootRedirect,
|
|
35
42
|
resolveRootRedirectDetailed,
|
|
36
43
|
setHubOrigin,
|
|
37
44
|
setModuleInstallChannel,
|
|
38
45
|
setNotesRedirectDisabled,
|
|
46
|
+
setRootMode,
|
|
39
47
|
setRootRedirect,
|
|
40
48
|
setSetting,
|
|
41
49
|
} from "../hub-settings.ts";
|
|
@@ -363,9 +371,9 @@ describe("hub-settings — module install channel bootstrap", () => {
|
|
|
363
371
|
// First read with env=rc — env wins, returns "rc". DB stays empty
|
|
364
372
|
// (no auto-write to DB when env is set, per the design that
|
|
365
373
|
// preserves the SPA's last-write as the env-unset fallback).
|
|
366
|
-
expect(
|
|
367
|
-
|
|
368
|
-
)
|
|
374
|
+
expect(getModuleInstallChannel(db, { env: { [PARACHUTE_INSTALL_CHANNEL_ENV]: "rc" } })).toBe(
|
|
375
|
+
"rc",
|
|
376
|
+
);
|
|
369
377
|
// Second read with env=latest — env wins again (would have returned
|
|
370
378
|
// "rc" under the old DB-after-first-seed behavior).
|
|
371
379
|
expect(
|
|
@@ -386,9 +394,9 @@ describe("hub-settings — module install channel bootstrap", () => {
|
|
|
386
394
|
const db = openHubDb(hubDbPath(dir));
|
|
387
395
|
try {
|
|
388
396
|
// PARACHUTE_INSTALL_CHANNEL — canonical name (matches install.ts).
|
|
389
|
-
expect(
|
|
390
|
-
|
|
391
|
-
)
|
|
397
|
+
expect(getModuleInstallChannel(db, { env: { [PARACHUTE_INSTALL_CHANNEL_ENV]: "rc" } })).toBe(
|
|
398
|
+
"rc",
|
|
399
|
+
);
|
|
392
400
|
// PARACHUTE_MODULE_CHANNEL — legacy alias, still recognized.
|
|
393
401
|
const db2 = openHubDb(join(mkdtempSync(join(tmpdir(), "phub-hsalt-")), "hub.db"));
|
|
394
402
|
try {
|
|
@@ -801,3 +809,99 @@ describe("hub-settings — root_redirect storage + resolution", () => {
|
|
|
801
809
|
expect(resolveRootRedirect(null, { env })).toBe("/surface/from-env");
|
|
802
810
|
});
|
|
803
811
|
});
|
|
812
|
+
|
|
813
|
+
describe("hub-settings — root_mode storage + resolution", () => {
|
|
814
|
+
let dir: string;
|
|
815
|
+
beforeEach(() => {
|
|
816
|
+
dir = mkdtempSync(join(tmpdir(), "hub-settings-root-mode-"));
|
|
817
|
+
});
|
|
818
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
819
|
+
|
|
820
|
+
const noEnv: NodeJS.ProcessEnv = {};
|
|
821
|
+
const silent = () => {};
|
|
822
|
+
|
|
823
|
+
test("isRootMode + ROOT_MODES", () => {
|
|
824
|
+
expect(ROOT_MODES).toEqual(["redirect", "serve-app"]);
|
|
825
|
+
expect(isRootMode("redirect")).toBe(true);
|
|
826
|
+
expect(isRootMode("serve-app")).toBe(true);
|
|
827
|
+
expect(isRootMode("serveapp")).toBe(false);
|
|
828
|
+
expect(isRootMode(42)).toBe(false);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
test("getRootMode round-trips serve-app via setRootMode", () => {
|
|
832
|
+
const db = openHubDb(hubDbPath(dir));
|
|
833
|
+
try {
|
|
834
|
+
expect(getRootMode(db)).toBeNull();
|
|
835
|
+
setRootMode(db, "serve-app");
|
|
836
|
+
expect(getRootMode(db)).toBe("serve-app");
|
|
837
|
+
} finally {
|
|
838
|
+
db.close();
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test("setRootMode(null) and setRootMode('redirect') both clear the row (default = absence)", () => {
|
|
843
|
+
const db = openHubDb(hubDbPath(dir));
|
|
844
|
+
try {
|
|
845
|
+
setRootMode(db, "serve-app");
|
|
846
|
+
setRootMode(db, null);
|
|
847
|
+
expect(getRootMode(db)).toBeNull();
|
|
848
|
+
setRootMode(db, "serve-app");
|
|
849
|
+
setRootMode(db, "redirect"); // the default is stored as absence
|
|
850
|
+
expect(getRootMode(db)).toBeNull();
|
|
851
|
+
expect(getSetting(db, "root_mode")).toBeUndefined();
|
|
852
|
+
} finally {
|
|
853
|
+
db.close();
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
test("resolves to the redirect default when neither DB nor env is set", () => {
|
|
858
|
+
const db = openHubDb(hubDbPath(dir));
|
|
859
|
+
try {
|
|
860
|
+
expect(resolveRootMode(db, { env: noEnv })).toBe(DEFAULT_ROOT_MODE);
|
|
861
|
+
expect(resolveRootModeDetailed(db, { env: noEnv })).toEqual({
|
|
862
|
+
value: "redirect",
|
|
863
|
+
source: "default",
|
|
864
|
+
});
|
|
865
|
+
} finally {
|
|
866
|
+
db.close();
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
test("env override applies when no DB row; DB overrides env (DB is tier-1)", () => {
|
|
871
|
+
const db = openHubDb(hubDbPath(dir));
|
|
872
|
+
try {
|
|
873
|
+
const env = { [PARACHUTE_HUB_ROOT_MODE_ENV]: "serve-app" };
|
|
874
|
+
expect(resolveRootModeDetailed(db, { env })).toEqual({ value: "serve-app", source: "env" });
|
|
875
|
+
setRootMode(db, "serve-app");
|
|
876
|
+
// A DB "serve-app" over an env "redirect" resolves to db.
|
|
877
|
+
expect(
|
|
878
|
+
resolveRootModeDetailed(db, { env: { [PARACHUTE_HUB_ROOT_MODE_ENV]: "redirect" } }),
|
|
879
|
+
).toEqual({ value: "serve-app", source: "db" });
|
|
880
|
+
} finally {
|
|
881
|
+
db.close();
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
test("an invalid DB row / env value is ignored → falls back to the redirect default", () => {
|
|
886
|
+
const db = openHubDb(hubDbPath(dir));
|
|
887
|
+
try {
|
|
888
|
+
setSetting(db, "root_mode", "bogus"); // hand-edited row bypassing write validation
|
|
889
|
+
expect(resolveRootMode(db, { env: noEnv, warn: silent })).toBe("redirect");
|
|
890
|
+
expect(getRootMode(db)).toBeNull(); // getRootMode also guards the invalid value
|
|
891
|
+
const env = { [PARACHUTE_HUB_ROOT_MODE_ENV]: "nonsense" };
|
|
892
|
+
expect(resolveRootModeDetailed(db, { env, warn: silent })).toEqual({
|
|
893
|
+
value: "redirect",
|
|
894
|
+
source: "default",
|
|
895
|
+
});
|
|
896
|
+
} finally {
|
|
897
|
+
db.close();
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
test("a null db (no state) resolves from env / default only", () => {
|
|
902
|
+
expect(resolveRootMode(null, { env: noEnv })).toBe("redirect");
|
|
903
|
+
expect(resolveRootMode(null, { env: { [PARACHUTE_HUB_ROOT_MODE_ENV]: "serve-app" } })).toBe(
|
|
904
|
+
"serve-app",
|
|
905
|
+
);
|
|
906
|
+
});
|
|
907
|
+
});
|
|
@@ -239,6 +239,35 @@ describe("renderHub — signed-out slimming (operator feedback)", () => {
|
|
|
239
239
|
});
|
|
240
240
|
});
|
|
241
241
|
|
|
242
|
+
describe("renderHub — Get started tiles are uis[]-driven (W2-12)", () => {
|
|
243
|
+
// The tile logic runs client-side inside the discovery IIFE; these pins
|
|
244
|
+
// assert the emitted script's SHAPE (the JS-parses check above guards its
|
|
245
|
+
// validity). The section is uis[]-driven — one tile per active uis[]
|
|
246
|
+
// sub-unit, whichever mount identity (/surface/notes or
|
|
247
|
+
// /surface/parachute) an install serves — with the pre-W2-12 hardcoded
|
|
248
|
+
// "Open Notes" tile demoted to a fallback for surface-host rows that
|
|
249
|
+
// predate uis{} self-registration.
|
|
250
|
+
const html = renderHub({
|
|
251
|
+
session: { displayName: "operator", csrfToken: "csrf-w212" },
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("the discovery script iterates uis[] sub-units", () => {
|
|
255
|
+
expect(html).toContain("svc.uis");
|
|
256
|
+
// Inactive sub-units are skipped; absent status counts as active.
|
|
257
|
+
expect(html).toContain("ui.status");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("the legacy 'Open Notes' hardcode survives only as the no-uis fallback", () => {
|
|
261
|
+
// Still present (pre-uis surface-host installs keep their CTA) …
|
|
262
|
+
expect(html).toContain("Open Notes");
|
|
263
|
+
expect(html).toContain("'/surface/notes/'");
|
|
264
|
+
// … but no longer keyed on the bare presence of parachute-surface as
|
|
265
|
+
// the PRIMARY path: the uis[] loop renders first, the hasSurface check
|
|
266
|
+
// only backstops an empty tile list.
|
|
267
|
+
expect(html).toContain("Legacy fallback");
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
242
271
|
describe("renderHub — signed-in indicator (rc.13)", () => {
|
|
243
272
|
test("session user → 'Signed in as <name>' + inline POST form with CSRF", () => {
|
|
244
273
|
const html = renderHub({
|