@openparachute/hub 0.5.10 → 0.5.12-rc.2
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-modules-ops.test.ts +283 -1
- package/src/__tests__/api-settings-hub-origin.test.ts +452 -0
- package/src/__tests__/bootstrap-token.test.ts +148 -0
- package/src/__tests__/hub-origin-resolution.test.ts +154 -0
- package/src/__tests__/hub-settings.test.ts +94 -0
- package/src/__tests__/oauth-ui.test.ts +117 -0
- package/src/__tests__/serve.test.ts +132 -1
- package/src/__tests__/setup-gate.test.ts +93 -0
- package/src/__tests__/setup-wizard.test.ts +392 -0
- package/src/api-modules-ops.ts +120 -1
- package/src/api-settings-hub-origin.ts +253 -0
- package/src/bootstrap-token.ts +153 -0
- package/src/commands/serve.ts +65 -1
- package/src/hub-server.ts +136 -18
- package/src/hub-settings.ts +53 -1
- package/src/oauth-ui.ts +45 -3
- package/src/setup-wizard.ts +178 -13
- package/src/well-known.ts +82 -1
- package/web/ui/dist/assets/index-BKFoB4gE.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-XhxYXDT5.js +0 -61
|
@@ -2300,3 +2300,395 @@ describe("typed vault name (hub#267)", () => {
|
|
|
2300
2300
|
expect(html).toContain('id="preview-vault-name">BAD<');
|
|
2301
2301
|
});
|
|
2302
2302
|
});
|
|
2303
|
+
|
|
2304
|
+
// --- bootstrap token gate (first-boot-path hardening, Issue 1) -----------
|
|
2305
|
+
|
|
2306
|
+
describe("bootstrap token gate (handleSetupAccountPost)", () => {
|
|
2307
|
+
let h: Harness;
|
|
2308
|
+
beforeEach(async () => {
|
|
2309
|
+
h = makeHarness();
|
|
2310
|
+
_resetOperationsRegistryForTests();
|
|
2311
|
+
const { _resetBootstrapTokenForTests } = await import("../bootstrap-token.ts");
|
|
2312
|
+
_resetBootstrapTokenForTests();
|
|
2313
|
+
});
|
|
2314
|
+
afterEach(async () => {
|
|
2315
|
+
h.cleanup();
|
|
2316
|
+
const { _resetBootstrapTokenForTests } = await import("../bootstrap-token.ts");
|
|
2317
|
+
_resetBootstrapTokenForTests();
|
|
2318
|
+
});
|
|
2319
|
+
|
|
2320
|
+
test("GET /admin/setup renders the bootstrap-token field when a token is active", async () => {
|
|
2321
|
+
const { generateBootstrapToken } = await import("../bootstrap-token.ts");
|
|
2322
|
+
generateBootstrapToken();
|
|
2323
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
2324
|
+
try {
|
|
2325
|
+
const res = handleSetupGet(req("/admin/setup"), {
|
|
2326
|
+
db,
|
|
2327
|
+
manifestPath: h.manifestPath,
|
|
2328
|
+
configDir: h.dir,
|
|
2329
|
+
issuer: "https://hub.example",
|
|
2330
|
+
registry: getDefaultOperationsRegistry(),
|
|
2331
|
+
});
|
|
2332
|
+
const html = await res.text();
|
|
2333
|
+
expect(html).toContain('name="bootstrap_token"');
|
|
2334
|
+
// The callout names what the field is + where to find the value.
|
|
2335
|
+
expect(html).toContain("Bootstrap token");
|
|
2336
|
+
expect(html).toContain("startup logs");
|
|
2337
|
+
// Form action unchanged.
|
|
2338
|
+
expect(html).toContain('action="/admin/setup/account"');
|
|
2339
|
+
} finally {
|
|
2340
|
+
db.close();
|
|
2341
|
+
}
|
|
2342
|
+
});
|
|
2343
|
+
|
|
2344
|
+
test("GET /admin/setup omits the bootstrap-token field when no token is active", () => {
|
|
2345
|
+
// No `generateBootstrapToken` call this test — the in-memory slot is
|
|
2346
|
+
// undefined, mirroring on-box CLI mode (no `parachute serve` wizard).
|
|
2347
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
2348
|
+
try {
|
|
2349
|
+
const res = handleSetupGet(req("/admin/setup"), {
|
|
2350
|
+
db,
|
|
2351
|
+
manifestPath: h.manifestPath,
|
|
2352
|
+
configDir: h.dir,
|
|
2353
|
+
issuer: "https://hub.example",
|
|
2354
|
+
registry: getDefaultOperationsRegistry(),
|
|
2355
|
+
});
|
|
2356
|
+
const html = res.text() as unknown as Promise<string>;
|
|
2357
|
+
return html.then((body) => {
|
|
2358
|
+
expect(body).not.toContain('name="bootstrap_token"');
|
|
2359
|
+
// Bootstrap callout is also absent — operator gets the
|
|
2360
|
+
// pre-hardening shape on the on-box CLI surface.
|
|
2361
|
+
expect(body).not.toContain("Bootstrap token");
|
|
2362
|
+
expect(body).toContain('action="/admin/setup/account"');
|
|
2363
|
+
});
|
|
2364
|
+
} finally {
|
|
2365
|
+
db.close();
|
|
2366
|
+
}
|
|
2367
|
+
});
|
|
2368
|
+
|
|
2369
|
+
test("POST /admin/setup/account with correct bootstrap_token creates admin + consumes token", async () => {
|
|
2370
|
+
const { generateBootstrapToken, getBootstrapToken } = await import("../bootstrap-token.ts");
|
|
2371
|
+
const token = generateBootstrapToken();
|
|
2372
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
2373
|
+
try {
|
|
2374
|
+
const get = handleSetupGet(req("/admin/setup"), {
|
|
2375
|
+
db,
|
|
2376
|
+
manifestPath: h.manifestPath,
|
|
2377
|
+
configDir: h.dir,
|
|
2378
|
+
issuer: "https://hub.example",
|
|
2379
|
+
registry: getDefaultOperationsRegistry(),
|
|
2380
|
+
});
|
|
2381
|
+
const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
|
|
2382
|
+
const form = formBody({
|
|
2383
|
+
bootstrap_token: token,
|
|
2384
|
+
username: "ops",
|
|
2385
|
+
password: "correct horse battery",
|
|
2386
|
+
password_confirm: "correct horse battery",
|
|
2387
|
+
[CSRF_FIELD_NAME]: csrf,
|
|
2388
|
+
});
|
|
2389
|
+
const post = await handleSetupAccountPost(
|
|
2390
|
+
req("/admin/setup/account", {
|
|
2391
|
+
method: "POST",
|
|
2392
|
+
body: form.body,
|
|
2393
|
+
headers: { ...form.headers, cookie: `${CSRF_COOKIE_NAME}=${csrf}` },
|
|
2394
|
+
}),
|
|
2395
|
+
{
|
|
2396
|
+
db,
|
|
2397
|
+
manifestPath: h.manifestPath,
|
|
2398
|
+
configDir: h.dir,
|
|
2399
|
+
issuer: "https://hub.example",
|
|
2400
|
+
registry: getDefaultOperationsRegistry(),
|
|
2401
|
+
},
|
|
2402
|
+
);
|
|
2403
|
+
expect(post.status).toBe(303);
|
|
2404
|
+
expect(userCount(db)).toBe(1);
|
|
2405
|
+
// Token consumed: in-memory slot is undefined.
|
|
2406
|
+
expect(getBootstrapToken()).toBeUndefined();
|
|
2407
|
+
} finally {
|
|
2408
|
+
db.close();
|
|
2409
|
+
}
|
|
2410
|
+
});
|
|
2411
|
+
|
|
2412
|
+
test("POST /admin/setup/account with wrong bootstrap_token returns 401 + no admin row", async () => {
|
|
2413
|
+
const { generateBootstrapToken } = await import("../bootstrap-token.ts");
|
|
2414
|
+
generateBootstrapToken();
|
|
2415
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
2416
|
+
try {
|
|
2417
|
+
const get = handleSetupGet(req("/admin/setup"), {
|
|
2418
|
+
db,
|
|
2419
|
+
manifestPath: h.manifestPath,
|
|
2420
|
+
configDir: h.dir,
|
|
2421
|
+
issuer: "https://hub.example",
|
|
2422
|
+
registry: getDefaultOperationsRegistry(),
|
|
2423
|
+
});
|
|
2424
|
+
const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
|
|
2425
|
+
const form = formBody({
|
|
2426
|
+
bootstrap_token: "parachute-bootstrap-WRONG-WRONG-WRONG-WRONG-WRONG-WRONG-x",
|
|
2427
|
+
username: "ops",
|
|
2428
|
+
password: "correct horse battery",
|
|
2429
|
+
password_confirm: "correct horse battery",
|
|
2430
|
+
[CSRF_FIELD_NAME]: csrf,
|
|
2431
|
+
});
|
|
2432
|
+
const post = await handleSetupAccountPost(
|
|
2433
|
+
req("/admin/setup/account", {
|
|
2434
|
+
method: "POST",
|
|
2435
|
+
body: form.body,
|
|
2436
|
+
headers: { ...form.headers, cookie: `${CSRF_COOKIE_NAME}=${csrf}` },
|
|
2437
|
+
}),
|
|
2438
|
+
{
|
|
2439
|
+
db,
|
|
2440
|
+
manifestPath: h.manifestPath,
|
|
2441
|
+
configDir: h.dir,
|
|
2442
|
+
issuer: "https://hub.example",
|
|
2443
|
+
registry: getDefaultOperationsRegistry(),
|
|
2444
|
+
},
|
|
2445
|
+
);
|
|
2446
|
+
expect(post.status).toBe(401);
|
|
2447
|
+
// The form re-renders with the token field present + an error
|
|
2448
|
+
// banner; the wrong token value is NOT echoed back.
|
|
2449
|
+
const html = await post.text();
|
|
2450
|
+
expect(html).toContain('name="bootstrap_token"');
|
|
2451
|
+
expect(html).toContain("Wrong bootstrap token");
|
|
2452
|
+
expect(html).not.toContain("WRONG-WRONG-WRONG");
|
|
2453
|
+
// Username is preserved so the operator doesn't have to retype.
|
|
2454
|
+
expect(html).toContain('value="ops"');
|
|
2455
|
+
// No admin row was created.
|
|
2456
|
+
expect(userCount(db)).toBe(0);
|
|
2457
|
+
} finally {
|
|
2458
|
+
db.close();
|
|
2459
|
+
}
|
|
2460
|
+
});
|
|
2461
|
+
|
|
2462
|
+
test("POST /admin/setup/account with MISSING bootstrap_token returns 401", async () => {
|
|
2463
|
+
const { generateBootstrapToken } = await import("../bootstrap-token.ts");
|
|
2464
|
+
generateBootstrapToken();
|
|
2465
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
2466
|
+
try {
|
|
2467
|
+
const get = handleSetupGet(req("/admin/setup"), {
|
|
2468
|
+
db,
|
|
2469
|
+
manifestPath: h.manifestPath,
|
|
2470
|
+
configDir: h.dir,
|
|
2471
|
+
issuer: "https://hub.example",
|
|
2472
|
+
registry: getDefaultOperationsRegistry(),
|
|
2473
|
+
});
|
|
2474
|
+
const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
|
|
2475
|
+
const form = formBody({
|
|
2476
|
+
// Deliberately omit `bootstrap_token`.
|
|
2477
|
+
username: "ops",
|
|
2478
|
+
password: "correct horse battery",
|
|
2479
|
+
password_confirm: "correct horse battery",
|
|
2480
|
+
[CSRF_FIELD_NAME]: csrf,
|
|
2481
|
+
});
|
|
2482
|
+
const post = await handleSetupAccountPost(
|
|
2483
|
+
req("/admin/setup/account", {
|
|
2484
|
+
method: "POST",
|
|
2485
|
+
body: form.body,
|
|
2486
|
+
headers: { ...form.headers, cookie: `${CSRF_COOKIE_NAME}=${csrf}` },
|
|
2487
|
+
}),
|
|
2488
|
+
{
|
|
2489
|
+
db,
|
|
2490
|
+
manifestPath: h.manifestPath,
|
|
2491
|
+
configDir: h.dir,
|
|
2492
|
+
issuer: "https://hub.example",
|
|
2493
|
+
registry: getDefaultOperationsRegistry(),
|
|
2494
|
+
},
|
|
2495
|
+
);
|
|
2496
|
+
expect(post.status).toBe(401);
|
|
2497
|
+
expect(userCount(db)).toBe(0);
|
|
2498
|
+
} finally {
|
|
2499
|
+
db.close();
|
|
2500
|
+
}
|
|
2501
|
+
});
|
|
2502
|
+
|
|
2503
|
+
test("POST /admin/setup/account after admin already claimed returns 410 Gone", async () => {
|
|
2504
|
+
const { generateBootstrapToken } = await import("../bootstrap-token.ts");
|
|
2505
|
+
// First claim: generate token + create admin. Then a stale POST
|
|
2506
|
+
// arrives — the token has been consumed AND an admin row exists.
|
|
2507
|
+
generateBootstrapToken();
|
|
2508
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
2509
|
+
try {
|
|
2510
|
+
await createUser(db, "first-admin", "strong-password", { passwordChanged: true });
|
|
2511
|
+
// Re-mint a fresh bootstrap token to simulate the case where the
|
|
2512
|
+
// operator restarts the hub after admin creation. (Normally the
|
|
2513
|
+
// serve.ts gate prevents this — we test the defensive layer.)
|
|
2514
|
+
generateBootstrapToken();
|
|
2515
|
+
const get = handleSetupGet(req("/admin/setup"), {
|
|
2516
|
+
db,
|
|
2517
|
+
manifestPath: h.manifestPath,
|
|
2518
|
+
configDir: h.dir,
|
|
2519
|
+
issuer: "https://hub.example",
|
|
2520
|
+
registry: getDefaultOperationsRegistry(),
|
|
2521
|
+
});
|
|
2522
|
+
const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
|
|
2523
|
+
const form = formBody({
|
|
2524
|
+
bootstrap_token: "parachute-bootstrap-doesnt-matter-admin-already-exists-xxx",
|
|
2525
|
+
username: "interloper",
|
|
2526
|
+
password: "another password",
|
|
2527
|
+
password_confirm: "another password",
|
|
2528
|
+
[CSRF_FIELD_NAME]: csrf,
|
|
2529
|
+
});
|
|
2530
|
+
const post = await handleSetupAccountPost(
|
|
2531
|
+
req("/admin/setup/account", {
|
|
2532
|
+
method: "POST",
|
|
2533
|
+
body: form.body,
|
|
2534
|
+
headers: { ...form.headers, cookie: `${CSRF_COOKIE_NAME}=${csrf}` },
|
|
2535
|
+
}),
|
|
2536
|
+
{
|
|
2537
|
+
db,
|
|
2538
|
+
manifestPath: h.manifestPath,
|
|
2539
|
+
configDir: h.dir,
|
|
2540
|
+
issuer: "https://hub.example",
|
|
2541
|
+
registry: getDefaultOperationsRegistry(),
|
|
2542
|
+
},
|
|
2543
|
+
);
|
|
2544
|
+
expect(post.status).toBe(410);
|
|
2545
|
+
const html = await post.text();
|
|
2546
|
+
expect(html).toContain("Admin already claimed");
|
|
2547
|
+
// No second user row was created.
|
|
2548
|
+
expect(userCount(db)).toBe(1);
|
|
2549
|
+
} finally {
|
|
2550
|
+
db.close();
|
|
2551
|
+
}
|
|
2552
|
+
});
|
|
2553
|
+
|
|
2554
|
+
test("on-box CLI flow (no token) creates admin normally — historical shape preserved", async () => {
|
|
2555
|
+
// Critical back-compat: when no token has been generated (the
|
|
2556
|
+
// historical wizard path: `parachute expose` doesn't enter wizard
|
|
2557
|
+
// mode), the account POST works exactly as before. This pins the
|
|
2558
|
+
// existing behavior post-refactor.
|
|
2559
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
2560
|
+
try {
|
|
2561
|
+
const get = handleSetupGet(req("/admin/setup"), {
|
|
2562
|
+
db,
|
|
2563
|
+
manifestPath: h.manifestPath,
|
|
2564
|
+
configDir: h.dir,
|
|
2565
|
+
issuer: "https://hub.example",
|
|
2566
|
+
registry: getDefaultOperationsRegistry(),
|
|
2567
|
+
});
|
|
2568
|
+
const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
|
|
2569
|
+
const form = formBody({
|
|
2570
|
+
username: "ops",
|
|
2571
|
+
password: "correct horse battery",
|
|
2572
|
+
password_confirm: "correct horse battery",
|
|
2573
|
+
[CSRF_FIELD_NAME]: csrf,
|
|
2574
|
+
});
|
|
2575
|
+
const post = await handleSetupAccountPost(
|
|
2576
|
+
req("/admin/setup/account", {
|
|
2577
|
+
method: "POST",
|
|
2578
|
+
body: form.body,
|
|
2579
|
+
headers: { ...form.headers, cookie: `${CSRF_COOKIE_NAME}=${csrf}` },
|
|
2580
|
+
}),
|
|
2581
|
+
{
|
|
2582
|
+
db,
|
|
2583
|
+
manifestPath: h.manifestPath,
|
|
2584
|
+
configDir: h.dir,
|
|
2585
|
+
issuer: "https://hub.example",
|
|
2586
|
+
registry: getDefaultOperationsRegistry(),
|
|
2587
|
+
},
|
|
2588
|
+
);
|
|
2589
|
+
expect(post.status).toBe(303);
|
|
2590
|
+
expect(userCount(db)).toBe(1);
|
|
2591
|
+
} finally {
|
|
2592
|
+
db.close();
|
|
2593
|
+
}
|
|
2594
|
+
});
|
|
2595
|
+
|
|
2596
|
+
// hub#297 reviewer-nit fold 3: pin the concurrent-claim race property.
|
|
2597
|
+
//
|
|
2598
|
+
// The wizard's account-claim POST has two layers of race-protection
|
|
2599
|
+
// (chain documented inline below). The vault-POST analogue (idempotent
|
|
2600
|
+
// short-circuit when supervisor.start is already running) has a test
|
|
2601
|
+
// at setup-wizard.test.ts:N2 (`handleSetupVaultPost` — idempotent on
|
|
2602
|
+
// concurrent POSTs); this is the missing partner pin for the account
|
|
2603
|
+
// step.
|
|
2604
|
+
//
|
|
2605
|
+
// Race-protection chain:
|
|
2606
|
+
// 1. First POST takes the token via verifyBootstrapToken (constant-
|
|
2607
|
+
// time check returns true), enters the createUser branch, and
|
|
2608
|
+
// consumes the token via consumeBootstrapToken AFTER the row
|
|
2609
|
+
// commits.
|
|
2610
|
+
// 2. Second POST (if it arrives before the first finishes):
|
|
2611
|
+
// a. If the first POST hasn't consumed the token yet, both
|
|
2612
|
+
// POSTs pass verifyBootstrapToken. They race into createUser;
|
|
2613
|
+
// SQLite's UNIQUE constraint on `users.username` makes the
|
|
2614
|
+
// second `INSERT INTO users` throw. The handler's `catch`
|
|
2615
|
+
// block re-renders the form with a 400 + "username may
|
|
2616
|
+
// already be taken" banner — and crucially does NOT create
|
|
2617
|
+
// a second admin row.
|
|
2618
|
+
// b. If the first POST has already consumed the token, the
|
|
2619
|
+
// second POST's verifyBootstrapToken returns false (token
|
|
2620
|
+
// slot is undefined). 401 + form re-render.
|
|
2621
|
+
// 3. Either way: exactly one admin row at rest, token consumed.
|
|
2622
|
+
test("concurrent claim with the same token + username yields exactly one admin row (race property)", async () => {
|
|
2623
|
+
const { generateBootstrapToken, getBootstrapToken } = await import("../bootstrap-token.ts");
|
|
2624
|
+
const token = generateBootstrapToken();
|
|
2625
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
2626
|
+
try {
|
|
2627
|
+
const get = handleSetupGet(req("/admin/setup"), {
|
|
2628
|
+
db,
|
|
2629
|
+
manifestPath: h.manifestPath,
|
|
2630
|
+
configDir: h.dir,
|
|
2631
|
+
issuer: "https://hub.example",
|
|
2632
|
+
registry: getDefaultOperationsRegistry(),
|
|
2633
|
+
});
|
|
2634
|
+
const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
|
|
2635
|
+
const formA = formBody({
|
|
2636
|
+
bootstrap_token: token,
|
|
2637
|
+
username: "ops",
|
|
2638
|
+
password: "correct horse battery",
|
|
2639
|
+
password_confirm: "correct horse battery",
|
|
2640
|
+
[CSRF_FIELD_NAME]: csrf,
|
|
2641
|
+
});
|
|
2642
|
+
// Two POSTs share the same body, same CSRF, same token, same
|
|
2643
|
+
// username. Promise.all fires them as concurrently as the bun
|
|
2644
|
+
// runtime allows; the deterministic interleaving covered here
|
|
2645
|
+
// is: both pass CSRF (same cookie), both pass token verify (if
|
|
2646
|
+
// they race before the first one's consume), both reach
|
|
2647
|
+
// createUser, and exactly one INSERT wins.
|
|
2648
|
+
const deps = {
|
|
2649
|
+
db,
|
|
2650
|
+
manifestPath: h.manifestPath,
|
|
2651
|
+
configDir: h.dir,
|
|
2652
|
+
issuer: "https://hub.example",
|
|
2653
|
+
registry: getDefaultOperationsRegistry(),
|
|
2654
|
+
};
|
|
2655
|
+
const post = (label: string) =>
|
|
2656
|
+
handleSetupAccountPost(
|
|
2657
|
+
req(`/admin/setup/account?race=${label}`, {
|
|
2658
|
+
method: "POST",
|
|
2659
|
+
body: formA.body,
|
|
2660
|
+
headers: { ...formA.headers, cookie: `${CSRF_COOKIE_NAME}=${csrf}` },
|
|
2661
|
+
}),
|
|
2662
|
+
deps,
|
|
2663
|
+
);
|
|
2664
|
+
|
|
2665
|
+
const [resA, resB] = await Promise.all([post("a"), post("b")]);
|
|
2666
|
+
|
|
2667
|
+
// Property 1: exactly one POST landed at the 303 success branch.
|
|
2668
|
+
const successes = [resA, resB].filter((r) => r.status === 303);
|
|
2669
|
+
const failures = [resA, resB].filter((r) => r.status !== 303);
|
|
2670
|
+
expect(successes.length).toBe(1);
|
|
2671
|
+
expect(failures.length).toBe(1);
|
|
2672
|
+
|
|
2673
|
+
// Property 2: the failure is either a 400 (UNIQUE collision via
|
|
2674
|
+
// createUser → catch block) OR a 401 (token already consumed by
|
|
2675
|
+
// the first POST → verifyBootstrapToken returned false on this
|
|
2676
|
+
// one). Both are valid race outcomes; we don't pin which —
|
|
2677
|
+
// the schedule is non-deterministic at the bun runtime layer.
|
|
2678
|
+
const failStatus = failures[0]?.status;
|
|
2679
|
+
expect(failStatus === 400 || failStatus === 401).toBe(true);
|
|
2680
|
+
|
|
2681
|
+
// Property 3: exactly one admin row exists in the users table.
|
|
2682
|
+
expect(userCount(db)).toBe(1);
|
|
2683
|
+
|
|
2684
|
+
// Property 4: the bootstrap token is consumed (cannot be reused
|
|
2685
|
+
// by a later POST). Even in the schedule where the second POST
|
|
2686
|
+
// failed via UNIQUE (token wasn't consumed by the failing path —
|
|
2687
|
+
// it was consumed by the succeeding path), the token slot is
|
|
2688
|
+
// empty after both promises settle.
|
|
2689
|
+
expect(getBootstrapToken()).toBeUndefined();
|
|
2690
|
+
} finally {
|
|
2691
|
+
db.close();
|
|
2692
|
+
}
|
|
2693
|
+
});
|
|
2694
|
+
});
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -34,12 +34,14 @@
|
|
|
34
34
|
|
|
35
35
|
import type { Database } from "bun:sqlite";
|
|
36
36
|
import { randomUUID } from "node:crypto";
|
|
37
|
+
import { dirname } from "node:path";
|
|
37
38
|
import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
|
|
38
39
|
import { getModuleInstallChannel } from "./hub-settings.ts";
|
|
39
40
|
import { validateAccessToken } from "./jwt-sign.ts";
|
|
40
41
|
import { FIRST_PARTY_FALLBACKS, type ServiceSpec, composeServiceSpec } from "./service-spec.ts";
|
|
41
|
-
import { findService, readManifest, removeService } from "./services-manifest.ts";
|
|
42
|
+
import { findService, readManifest, removeService, upsertService } from "./services-manifest.ts";
|
|
42
43
|
import type { ModuleState, SpawnRequest, Supervisor } from "./supervisor.ts";
|
|
44
|
+
import { regenerateWellKnown } from "./well-known.ts";
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
47
|
* Scope required for every POST + operation-poll endpoint here.
|
|
@@ -186,6 +188,20 @@ export interface ApiModulesOpsDeps {
|
|
|
186
188
|
* inside `Bun.spawn` at child spawn time; we don't mutate `process.env`.
|
|
187
189
|
*/
|
|
188
190
|
spawnEnv?: Record<string, string>;
|
|
191
|
+
/**
|
|
192
|
+
* Override the on-disk path for the regenerated `/.well-known/parachute.json`
|
|
193
|
+
* (test seam). Production writes to `WELL_KNOWN_PATH`; tests point at a
|
|
194
|
+
* tmp file so assertions can read the resulting doc without touching the
|
|
195
|
+
* operator's real config dir.
|
|
196
|
+
*/
|
|
197
|
+
wellKnownPath?: string;
|
|
198
|
+
/**
|
|
199
|
+
* Reader for `<installDir>/.parachute/module.json` used by the well-known
|
|
200
|
+
* regen. Production reads from disk; tests inject a fake. Mirrors the
|
|
201
|
+
* hub-server `readModuleManifest` seam so the disk regen and the
|
|
202
|
+
* per-request HTTP build stay aligned.
|
|
203
|
+
*/
|
|
204
|
+
readModuleManifest?: Parameters<typeof regenerateWellKnown>[0]["readModuleManifest"];
|
|
189
205
|
}
|
|
190
206
|
|
|
191
207
|
interface PathMatch {
|
|
@@ -266,6 +282,61 @@ function defaultRun(cmd: readonly string[]): Promise<number> {
|
|
|
266
282
|
return proc.exited;
|
|
267
283
|
}
|
|
268
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Stamp `installDir` on the services.json row for `spec.manifestName` when
|
|
287
|
+
* the package's globally-installed path can be resolved. Idempotent —
|
|
288
|
+
* no-ops when the row already carries the same `installDir`, when no row
|
|
289
|
+
* exists, or when `findGlobalInstall` can't locate the package (e.g. in
|
|
290
|
+
* tests with no global install).
|
|
291
|
+
*
|
|
292
|
+
* Mirrors the same stamp `parachute install <svc>` does in
|
|
293
|
+
* `commands/install.ts`. Without it, the discovery page's
|
|
294
|
+
* `loadServiceUiMetadata` resolver in `hub-server.ts` skips the entry (it
|
|
295
|
+
* reads `module.json` from `installDir`), so `uiUrl` never lands on the
|
|
296
|
+
* row + the new module's tile never renders on `/`.
|
|
297
|
+
*/
|
|
298
|
+
function stampInstallDir(spec: ServiceSpec, deps: ApiModulesOpsDeps): void {
|
|
299
|
+
const findGlobalInstall = deps.findGlobalInstall;
|
|
300
|
+
if (!findGlobalInstall) return;
|
|
301
|
+
const pkgJson = findGlobalInstall(spec.package);
|
|
302
|
+
if (!pkgJson) return;
|
|
303
|
+
const installDir = dirname(pkgJson);
|
|
304
|
+
const entry = findService(spec.manifestName, deps.manifestPath);
|
|
305
|
+
if (!entry || entry.installDir === installDir) return;
|
|
306
|
+
upsertService({ ...entry, installDir }, deps.manifestPath);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Regenerate `/.well-known/parachute.json` on disk after a state-changing
|
|
311
|
+
* module op (install / upgrade / uninstall). Wraps `regenerateWellKnown`
|
|
312
|
+
* with the deps-aware defaults — issuer for canonicalOrigin, deps-overridable
|
|
313
|
+
* paths + manifest reader. Errors land in the operation log rather than
|
|
314
|
+
* throwing back to the caller; the on-disk doc is a debug / inspection
|
|
315
|
+
* artifact, not the live discovery source, so a regen failure shouldn't
|
|
316
|
+
* mask the op's actual outcome.
|
|
317
|
+
*/
|
|
318
|
+
async function regenAfterOp(
|
|
319
|
+
opId: string,
|
|
320
|
+
registry: OperationsRegistry,
|
|
321
|
+
deps: ApiModulesOpsDeps,
|
|
322
|
+
): Promise<void> {
|
|
323
|
+
try {
|
|
324
|
+
const regenOpts: Parameters<typeof regenerateWellKnown>[0] = {
|
|
325
|
+
manifestPath: deps.manifestPath,
|
|
326
|
+
canonicalOrigin: deps.issuer,
|
|
327
|
+
};
|
|
328
|
+
if (deps.wellKnownPath !== undefined) regenOpts.wellKnownPath = deps.wellKnownPath;
|
|
329
|
+
if (deps.readModuleManifest !== undefined) {
|
|
330
|
+
regenOpts.readModuleManifest = deps.readModuleManifest;
|
|
331
|
+
}
|
|
332
|
+
const { path } = await regenerateWellKnown(regenOpts);
|
|
333
|
+
registry.update(opId, {}, `regenerated ${path}`);
|
|
334
|
+
} catch (err) {
|
|
335
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
336
|
+
registry.update(opId, {}, `well-known regen failed: ${msg}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
269
340
|
/**
|
|
270
341
|
* Spawn the supervised child for `short`, using the spec's startCmd
|
|
271
342
|
* and the current services.json entry (so notes' port-derived
|
|
@@ -392,6 +463,14 @@ export async function runInstall(
|
|
|
392
463
|
}
|
|
393
464
|
}
|
|
394
465
|
|
|
466
|
+
// Stamp installDir on the row so the discovery page's `uiUrl` /
|
|
467
|
+
// `displayName` resolver can find `<installDir>/.parachute/module.json`.
|
|
468
|
+
// Mirrors `parachute install <svc>` — without this, the new module's
|
|
469
|
+
// tile never renders on `/` because `loadServiceUiMetadata` skips
|
|
470
|
+
// installDir-less rows. (Doing this BEFORE the spawn so the supervisor
|
|
471
|
+
// also sees the updated row if it consults services.json post-spawn.)
|
|
472
|
+
stampInstallDir(spec, deps);
|
|
473
|
+
|
|
395
474
|
// Spawn the child via the supervisor. Boot-spawn semantics apply.
|
|
396
475
|
const state = await spawnSupervised(short, spec, deps);
|
|
397
476
|
if (!state) {
|
|
@@ -402,6 +481,14 @@ export async function runInstall(
|
|
|
402
481
|
);
|
|
403
482
|
return;
|
|
404
483
|
}
|
|
484
|
+
|
|
485
|
+
// Regenerate the on-disk well-known doc so the inspection artifact
|
|
486
|
+
// tracks the just-installed module's row. The HTTP path serves a
|
|
487
|
+
// per-request build, so the discovery page picks up the new module
|
|
488
|
+
// either way; this keeps `~/.parachute/well-known/parachute.json` in
|
|
489
|
+
// sync for tooling that reads it directly.
|
|
490
|
+
await regenAfterOp(opId, registry, deps);
|
|
491
|
+
|
|
405
492
|
registry.update(opId, { status: "succeeded" }, `${short} installed + spawned (pid ${state.pid})`);
|
|
406
493
|
}
|
|
407
494
|
|
|
@@ -485,6 +572,11 @@ async function runUpgrade(
|
|
|
485
572
|
registry.update(opId, {}, `bun add reported exit ${code} but package landed at ${probed}`);
|
|
486
573
|
}
|
|
487
574
|
|
|
575
|
+
// Re-stamp installDir after upgrade — a major-version bump may relocate
|
|
576
|
+
// the package on disk (e.g. node_modules layout change). Idempotent
|
|
577
|
+
// when the path is stable.
|
|
578
|
+
stampInstallDir(spec, deps);
|
|
579
|
+
|
|
488
580
|
const state = await deps.supervisor.restart(short);
|
|
489
581
|
if (!state) {
|
|
490
582
|
registry.update(
|
|
@@ -494,6 +586,13 @@ async function runUpgrade(
|
|
|
494
586
|
);
|
|
495
587
|
return;
|
|
496
588
|
}
|
|
589
|
+
|
|
590
|
+
// Refresh the on-disk well-known so the version field on the upgraded
|
|
591
|
+
// module's row reflects the new install. The HTTP path rebuilds per
|
|
592
|
+
// request, so the discovery page tracks the new version regardless;
|
|
593
|
+
// this keeps the inspection artifact aligned.
|
|
594
|
+
await regenAfterOp(opId, registry, deps);
|
|
595
|
+
|
|
497
596
|
registry.update(
|
|
498
597
|
opId,
|
|
499
598
|
{ status: "succeeded" },
|
|
@@ -540,6 +639,26 @@ export async function handleUninstall(
|
|
|
540
639
|
const code = await run(["bun", "remove", "-g", spec.package]);
|
|
541
640
|
log.push(`bun remove -g ${spec.package} exited ${code}`);
|
|
542
641
|
|
|
642
|
+
// 4. Refresh the on-disk well-known so the uninstalled module no
|
|
643
|
+
// longer appears in the inspection artifact. The HTTP path rebuilds
|
|
644
|
+
// per request, so live discovery drops the entry immediately; this
|
|
645
|
+
// is the disk-side equivalent.
|
|
646
|
+
try {
|
|
647
|
+
const regenOpts: Parameters<typeof regenerateWellKnown>[0] = {
|
|
648
|
+
manifestPath: deps.manifestPath,
|
|
649
|
+
canonicalOrigin: deps.issuer,
|
|
650
|
+
};
|
|
651
|
+
if (deps.wellKnownPath !== undefined) regenOpts.wellKnownPath = deps.wellKnownPath;
|
|
652
|
+
if (deps.readModuleManifest !== undefined) {
|
|
653
|
+
regenOpts.readModuleManifest = deps.readModuleManifest;
|
|
654
|
+
}
|
|
655
|
+
const { path } = await regenerateWellKnown(regenOpts);
|
|
656
|
+
log.push(`regenerated ${path}`);
|
|
657
|
+
} catch (err) {
|
|
658
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
659
|
+
log.push(`well-known regen failed: ${msg}`);
|
|
660
|
+
}
|
|
661
|
+
|
|
543
662
|
return jsonOk({ short, log });
|
|
544
663
|
}
|
|
545
664
|
|