@openparachute/hub 0.7.7-rc.12 → 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/package.json +1 -1
- package/src/__tests__/api-settings-root-redirect.test.ts +148 -5
- package/src/__tests__/hub-command.test.ts +70 -2
- package/src/__tests__/hub-server.test.ts +285 -1
- package/src/__tests__/hub-settings.test.ts +110 -6
- package/src/__tests__/install.test.ts +115 -35
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/api-settings-root-redirect.ts +95 -19
- package/src/commands/hub.ts +104 -0
- package/src/commands/install.ts +30 -19
- package/src/hub-server.ts +71 -3
- package/src/hub-settings.ts +137 -0
- package/src/root-serve.ts +156 -0
- package/src/service-spec.ts +3 -2
package/package.json
CHANGED
|
@@ -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,14 @@ 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
|
+
});
|
|
196
204
|
});
|
|
197
205
|
|
|
198
206
|
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
@@ -204,7 +212,14 @@ describe("GET /api/settings/root-redirect", () => {
|
|
|
204
212
|
);
|
|
205
213
|
expect(res.status).toBe(200);
|
|
206
214
|
const body = (await res.json()) as Record<string, unknown>;
|
|
207
|
-
expect(body).toEqual({
|
|
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
|
+
});
|
|
208
223
|
});
|
|
209
224
|
|
|
210
225
|
test("reflects a stored value with source=db", async () => {
|
|
@@ -219,6 +234,9 @@ describe("GET /api/settings/root-redirect", () => {
|
|
|
219
234
|
root_redirect: "/surface/reading-room",
|
|
220
235
|
resolved: "/surface/reading-room",
|
|
221
236
|
source: "db",
|
|
237
|
+
root_mode: null,
|
|
238
|
+
resolved_mode: "redirect",
|
|
239
|
+
mode_source: "default",
|
|
222
240
|
});
|
|
223
241
|
});
|
|
224
242
|
|
|
@@ -233,6 +251,9 @@ describe("GET /api/settings/root-redirect", () => {
|
|
|
233
251
|
root_redirect: null,
|
|
234
252
|
resolved: "/surface/from-env",
|
|
235
253
|
source: "env",
|
|
254
|
+
root_mode: null,
|
|
255
|
+
resolved_mode: "redirect",
|
|
256
|
+
mode_source: "default",
|
|
236
257
|
});
|
|
237
258
|
});
|
|
238
259
|
});
|
|
@@ -251,7 +272,10 @@ describe("PUT /api/settings/root-redirect", () => {
|
|
|
251
272
|
deps(h),
|
|
252
273
|
);
|
|
253
274
|
expect(put.status).toBe(200);
|
|
254
|
-
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
|
+
});
|
|
255
279
|
expect(getRootRedirect(h.db)).toBe("/surface/reading-room");
|
|
256
280
|
|
|
257
281
|
const get = await handleApiSettingsRootRedirect(
|
|
@@ -294,7 +318,7 @@ describe("PUT /api/settings/root-redirect", () => {
|
|
|
294
318
|
}
|
|
295
319
|
});
|
|
296
320
|
|
|
297
|
-
test("400 on a body
|
|
321
|
+
test("400 on a body with neither root_redirect nor root_mode", async () => {
|
|
298
322
|
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
299
323
|
const res = await handleApiSettingsRootRedirect(
|
|
300
324
|
putReq({ wrong: "x" }, { authorization: `Bearer ${bearer}` }),
|
|
@@ -312,3 +336,122 @@ describe("PUT /api/settings/root-redirect", () => {
|
|
|
312
336
|
expect(res.status).toBe(400);
|
|
313
337
|
});
|
|
314
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
|
+
});
|
|
@@ -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";
|
|
@@ -6418,3 +6418,287 @@ describe("hubFetch proxied-page security headers (hub#643 Tier-1)", () => {
|
|
|
6418
6418
|
}
|
|
6419
6419
|
});
|
|
6420
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
|
+
});
|