@openparachute/hub 0.7.7-rc.7 → 0.7.7-rc.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.7-rc.7",
3
+ "version": "0.7.7-rc.9",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test";
3
3
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { checkAccountDescriptor, validateVaultScopes } from "@openparachute/door-contract";
6
7
  import {
7
8
  handleAccountCapabilities,
8
9
  handleAccountCreateVault,
@@ -12,7 +13,10 @@ import {
12
13
  handleAccountRoot,
13
14
  handleAccountSetVaultCaps,
14
15
  } from "../account-api.ts";
16
+ import { handleCreateInvite } from "../api-invites.ts";
15
17
  import { hubDbPath, openHubDb } from "../hub-db.ts";
18
+ import { getSetting } from "../hub-settings.ts";
19
+ import { findInviteByRawToken, recordInviteRedemption, revokeInvite } from "../invites.ts";
16
20
  import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
17
21
  import { upsertService } from "../services-manifest.ts";
18
22
  import { rotateSigningKey } from "../signing-keys.ts";
@@ -115,31 +119,251 @@ function jsonReq(path: string, token: string, method: string, body?: unknown): R
115
119
  // GET /.well-known/parachute-account — the capabilities descriptor
116
120
  // ===========================================================================
117
121
  describe("handleAccountCapabilities", () => {
118
- test("descriptor is public and reports the self-host door honestly", async () => {
119
- const res = handleAccountCapabilities(new Request(`${ISSUER}/.well-known/parachute-account`), {
120
- issuer: `${ISSUER}/`,
121
- });
122
- expect(res.status).toBe(200);
123
- const body = (await res.json()) as Record<string, unknown>;
124
- expect(body.door).toBe("self-host");
125
- expect(body.issuer).toBe(ISSUER); // trailing slash trimmed
126
- const features = body.features as Record<string, unknown>;
127
- expect(features.billing).toBe(false); // no billing on self-host
128
- expect(features.plans).toEqual([]);
129
- expect(features.vault_create).toBe(true);
130
- expect(features.vault_delete).toBe(true);
131
- expect(features.modules).toBe(true);
132
- expect(features.expose).toBe(true);
133
- expect(body.caps_writable).toBe(true);
134
- expect((body.limits as Record<string, unknown>).vaults_max).toBeNull();
122
+ test("descriptor is public, wildcard-CORS, and conforms to the shared door-contract canon", async () => {
123
+ const h = makeHarness();
124
+ try {
125
+ const res = handleAccountCapabilities(
126
+ new Request(`${ISSUER}/.well-known/parachute-account`),
127
+ { db: h.db, issuer: `${ISSUER}/` },
128
+ );
129
+ expect(res.status).toBe(200);
130
+ expect(res.headers.get("access-control-allow-origin")).toBe("*");
131
+ expect(res.headers.get("access-control-allow-methods")).toBe("GET, OPTIONS");
132
+ const body = (await res.json()) as Record<string, unknown>;
133
+ // The shared conformance checker — empty issue list means the shape
134
+ // is byte-conformant with the canonical ParachuteAccountDescriptor.
135
+ expect(checkAccountDescriptor(body, { issuer: ISSUER, door: "hub" })).toEqual([]);
136
+ expect(body.issuer).toBe(ISSUER); // trailing slash trimmed
137
+ expect(body.door).toBe("hub");
138
+ expect(body.account_endpoint).toBe(`${ISSUER}/account`);
139
+ expect(body.auth).toEqual({ methods: ["password"], signin_path: "/login" });
140
+ expect(body.vault_url_template).toContain("{name}");
141
+ expect(body.vault_url_template).toBe(`${ISSUER}/vault/{name}`);
142
+ expect(body.capabilities).toEqual({
143
+ vault_create: true,
144
+ vault_rename: false,
145
+ vault_delete: true,
146
+ });
147
+ expect(body.plans).toEqual([]);
148
+ expect(body.signup_path).toBeUndefined(); // fresh hub, no active public invite
149
+ // Hub extras — additive, outside the shared contract (conformance
150
+ // walks expected keys only, so these ride along without breaking it).
151
+ const features = body.features as Record<string, unknown>;
152
+ expect(features.billing).toBe(false);
153
+ expect(features.modules).toBe(true);
154
+ expect(features.expose).toBe(true);
155
+ expect(features.import).toBe(true);
156
+ expect(features.export).toBe(true);
157
+ expect(body.caps_writable).toBe(true);
158
+ } finally {
159
+ h.cleanup();
160
+ }
135
161
  });
136
162
 
137
163
  test("405 on non-GET", () => {
138
- const res = handleAccountCapabilities(
139
- new Request(`${ISSUER}/.well-known/parachute-account`, { method: "POST" }),
140
- { issuer: ISSUER },
141
- );
142
- expect(res.status).toBe(405);
164
+ const h = makeHarness();
165
+ try {
166
+ const res = handleAccountCapabilities(
167
+ new Request(`${ISSUER}/.well-known/parachute-account`, { method: "POST" }),
168
+ { db: h.db, issuer: ISSUER },
169
+ );
170
+ expect(res.status).toBe(405);
171
+ } finally {
172
+ h.cleanup();
173
+ }
174
+ });
175
+
176
+ // -------------------------------------------------------------------------
177
+ // Q2 — the conditional `signup_path`. Present only while an active
178
+ // multi-use (public-signup) invite exists; absent on a fresh hub and
179
+ // absent again once the invite is revoked / exhausted / expired (the
180
+ // setting is lazily cleared on the next read, invites.ts's
181
+ // activePublicSignupPath).
182
+ // -------------------------------------------------------------------------
183
+ test("signup_path appears after minting a max_uses:3 invite, disappears on revoke", async () => {
184
+ const h = makeHarness();
185
+ try {
186
+ const admin = await createUser(h.db, "operator", "any-password", {
187
+ allowMulti: true,
188
+ passwordChanged: true,
189
+ });
190
+ const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
191
+ const mintRes = await handleCreateInvite(
192
+ jsonReq("/api/invites", adminToken, "POST", { max_uses: 3 }),
193
+ { db: h.db, issuer: ISSUER },
194
+ );
195
+ expect(mintRes.status).toBe(201);
196
+ const minted = (await mintRes.json()) as { token: string };
197
+
198
+ const afterMint = handleAccountCapabilities(
199
+ new Request(`${ISSUER}/.well-known/parachute-account`),
200
+ { db: h.db, issuer: ISSUER },
201
+ );
202
+ const afterMintBody = (await afterMint.json()) as Record<string, unknown>;
203
+ expect(afterMintBody.signup_path).toBe(`/account/setup/${minted.token}`);
204
+
205
+ const invite = findInviteByRawToken(h.db, minted.token);
206
+ expect(invite).not.toBeNull();
207
+ expect(revokeInvite(h.db, invite?.tokenHash ?? "")).toBe(true);
208
+
209
+ const afterRevoke = handleAccountCapabilities(
210
+ new Request(`${ISSUER}/.well-known/parachute-account`),
211
+ { db: h.db, issuer: ISSUER },
212
+ );
213
+ expect(((await afterRevoke.json()) as Record<string, unknown>).signup_path).toBeUndefined();
214
+ expect(getSetting(h.db, "public_signup_token")).toBeUndefined(); // lazily cleared
215
+ } finally {
216
+ h.cleanup();
217
+ }
218
+ });
219
+
220
+ test("signup_path disappears once the invite is exhausted (used_count == max_uses)", async () => {
221
+ const h = makeHarness();
222
+ try {
223
+ const admin = await createUser(h.db, "operator", "any-password", {
224
+ allowMulti: true,
225
+ passwordChanged: true,
226
+ });
227
+ const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
228
+ const mintRes = await handleCreateInvite(
229
+ jsonReq("/api/invites", adminToken, "POST", { max_uses: 2 }),
230
+ { db: h.db, issuer: ISSUER },
231
+ );
232
+ const minted = (await mintRes.json()) as { token: string };
233
+ const invite = findInviteByRawToken(h.db, minted.token);
234
+ expect(invite).not.toBeNull();
235
+
236
+ // Redeem both seats directly (invites.ts core — the redeem HTTP flow
237
+ // itself is out of scope here). `redeemed_user_id` FKs to `users`, so
238
+ // reuse the admin row as the redeemer stand-in — the test only cares
239
+ // that TWO redemptions land, not who they belong to.
240
+ expect(recordInviteRedemption(h.db, invite?.tokenHash ?? "", admin.id)).toBe(true);
241
+ expect(recordInviteRedemption(h.db, invite?.tokenHash ?? "", admin.id)).toBe(true);
242
+
243
+ const res = handleAccountCapabilities(
244
+ new Request(`${ISSUER}/.well-known/parachute-account`),
245
+ { db: h.db, issuer: ISSUER },
246
+ );
247
+ expect(((await res.json()) as Record<string, unknown>).signup_path).toBeUndefined();
248
+ expect(getSetting(h.db, "public_signup_token")).toBeUndefined();
249
+ } finally {
250
+ h.cleanup();
251
+ }
252
+ });
253
+
254
+ test("signup_path disappears once the invite expires (injected clock)", async () => {
255
+ const h = makeHarness();
256
+ try {
257
+ const admin = await createUser(h.db, "operator", "any-password", {
258
+ allowMulti: true,
259
+ passwordChanged: true,
260
+ });
261
+ const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
262
+ const mintTime = new Date("2026-01-01T00:00:00.000Z");
263
+ const mintRes = await handleCreateInvite(
264
+ jsonReq("/api/invites", adminToken, "POST", { max_uses: 5, expires_in: 60 }),
265
+ { db: h.db, issuer: ISSUER, now: () => mintTime },
266
+ );
267
+ expect(mintRes.status).toBe(201);
268
+
269
+ const stillLive = handleAccountCapabilities(
270
+ new Request(`${ISSUER}/.well-known/parachute-account`),
271
+ { db: h.db, issuer: ISSUER, now: () => new Date(mintTime.getTime() + 30_000) },
272
+ );
273
+ expect(((await stillLive.json()) as Record<string, unknown>).signup_path).toBeDefined();
274
+
275
+ const afterExpiry = handleAccountCapabilities(
276
+ new Request(`${ISSUER}/.well-known/parachute-account`),
277
+ { db: h.db, issuer: ISSUER, now: () => new Date(mintTime.getTime() + 120_000) },
278
+ );
279
+ expect(((await afterExpiry.json()) as Record<string, unknown>).signup_path).toBeUndefined();
280
+ expect(getSetting(h.db, "public_signup_token")).toBeUndefined();
281
+ } finally {
282
+ h.cleanup();
283
+ }
284
+ });
285
+
286
+ test("signup_path is NOT set for a multi-use NON-provisioning invite (B1: a team link must never leak on the public descriptor)", async () => {
287
+ const h = makeHarness();
288
+ try {
289
+ const admin = await createUser(h.db, "operator", "any-password", {
290
+ allowMulti: true,
291
+ passwordChanged: true,
292
+ });
293
+ const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
294
+ // A multi-use link with provision_vault=false is a team invite (here
295
+ // account-only; a shared-vault one additionally pins an existing
296
+ // vault_name and would hand out team-vault WRITE) — NOT a public signup
297
+ // that gives each redeemer their own fresh vault. It must never be
298
+ // persisted to public_signup_token nor advertised via the anonymous,
299
+ // wildcard-CORS descriptor. Same `provisionVault` guard covers both the
300
+ // account-only and shared-vault non-provisioning shapes.
301
+ const mintRes = await handleCreateInvite(
302
+ jsonReq("/api/invites", adminToken, "POST", { max_uses: 3, provision_vault: false }),
303
+ { db: h.db, issuer: ISSUER },
304
+ );
305
+ expect(mintRes.status).toBe(201);
306
+ // The raw token was NOT persisted (the write-side guard)...
307
+ expect(getSetting(h.db, "public_signup_token")).toBeUndefined();
308
+ // ...and the public descriptor advertises no signup_path.
309
+ const desc = handleAccountCapabilities(
310
+ new Request(`${ISSUER}/.well-known/parachute-account`),
311
+ { db: h.db, issuer: ISSUER },
312
+ );
313
+ expect(((await desc.json()) as Record<string, unknown>).signup_path).toBeUndefined();
314
+ } finally {
315
+ h.cleanup();
316
+ }
317
+ });
318
+ });
319
+
320
+ // ===========================================================================
321
+ // The shared door-contract validateVaultScopes — behavioral no-op proof
322
+ // (hub-parity P2, item 5). These are the SAME fixtures the hub's prior local
323
+ // `parseScopesBody` scope-shape logic was exercised against (see the
324
+ // `handleAccountMintVaultToken` describe block below for the HTTP-level
325
+ // equivalents); asserting them directly against the shared validator proves
326
+ // the swap didn't change hub's wire behavior.
327
+ // ===========================================================================
328
+ describe("validateVaultScopes (shared door-contract validator) — hub's prior fixtures", () => {
329
+ test("undefined / null / [] all default to read+write for the named vault", () => {
330
+ for (const requested of [undefined, null, []]) {
331
+ expect(validateVaultScopes(requested, "field-notes")).toEqual({
332
+ ok: true,
333
+ scopes: ["vault:field-notes:read", "vault:field-notes:write"],
334
+ });
335
+ }
336
+ });
337
+
338
+ test("an explicit single-scope array is honored verbatim", () => {
339
+ expect(validateVaultScopes(["vault:field-notes:read"], "field-notes")).toEqual({
340
+ ok: true,
341
+ scopes: ["vault:field-notes:read"],
342
+ });
343
+ });
344
+
345
+ test("a scope naming another vault is invalid_scope", () => {
346
+ expect(validateVaultScopes(["vault:other:read"], "field-notes")).toEqual({
347
+ ok: false,
348
+ reason: "invalid_scope",
349
+ });
350
+ });
351
+
352
+ test("a non-array requested value is invalid_request", () => {
353
+ expect(validateVaultScopes("not-an-array", "field-notes")).toEqual({
354
+ ok: false,
355
+ reason: "invalid_request",
356
+ });
357
+ });
358
+
359
+ test("a mixed array with a non-string entry is invalid_request, even when a valid scope leads", () => {
360
+ // The whole-array pre-scan (byte-exact with hub's prior parseScopesBody):
361
+ // a non-string entry anywhere rejects the WHOLE request as
362
+ // invalid_request, never a positional invalid_scope.
363
+ expect(validateVaultScopes(["vault:field-notes:read", 123], "field-notes")).toEqual({
364
+ ok: false,
365
+ reason: "invalid_request",
366
+ });
143
367
  });
144
368
  });
145
369
 
@@ -327,6 +551,26 @@ describe("handleAccountCreateVault", () => {
327
551
  h.cleanup();
328
552
  }
329
553
  });
554
+
555
+ // Q6 (hub-parity P2): create-existing-name converges on 409 vault_taken —
556
+ // this facade no longer answers 200-idempotent. `provisionVault` itself
557
+ // stays idempotent (the invite-redeem caller doesn't route through here).
558
+ test("409 vault_taken on an existing vault name (Q6 — no longer 200-idempotent)", async () => {
559
+ const h = makeHarness(["beta", "personal"]);
560
+ try {
561
+ const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
562
+ const res = await handleAccountCreateVault(
563
+ jsonReq("/account/vaults", token, "POST", { name: "beta" }),
564
+ deps(h),
565
+ );
566
+ expect(res.status).toBe(409);
567
+ const body = (await res.json()) as { error: string; message: string };
568
+ expect(body.error).toBe("vault_taken");
569
+ expect(body.message).toBe("That vault name is already taken.");
570
+ } finally {
571
+ h.cleanup();
572
+ }
573
+ });
330
574
  });
331
575
 
332
576
  // ===========================================================================
@@ -489,7 +733,7 @@ describe("account caps", () => {
489
733
  // GET /account — bootstrap
490
734
  // ===========================================================================
491
735
  describe("handleAccountRoot", () => {
492
- test("returns account_id=self, door=self-host, and the operator email", async () => {
736
+ test("returns the contract's AccountBootstrap — {id:self, door:hub, email}", async () => {
493
737
  const h = makeHarness();
494
738
  try {
495
739
  const user = await createUser(h.db, "operator", "any-password", {
@@ -500,10 +744,26 @@ describe("handleAccountRoot", () => {
500
744
  const token = await bearer(h, [ACCOUNT_READ_SCOPE], user.id);
501
745
  const res = await handleAccountRoot(withBearer("/account", token), deps(h));
502
746
  expect(res.status).toBe(200);
503
- const body = (await res.json()) as { account_id: string; email: string | null; door: string };
504
- expect(body.account_id).toBe("self");
505
- expect(body.door).toBe("self-host");
506
- expect(body.email).toBe("op@example.com");
747
+ const body = (await res.json()) as { id: string; email?: string; door: string };
748
+ expect(body).toEqual({ id: "self", door: "hub", email: "op@example.com" });
749
+ } finally {
750
+ h.cleanup();
751
+ }
752
+ });
753
+
754
+ test("omits email when the operator row has none", async () => {
755
+ const h = makeHarness();
756
+ try {
757
+ const user = await createUser(h.db, "operator", "any-password", {
758
+ allowMulti: true,
759
+ passwordChanged: true,
760
+ });
761
+ const token = await bearer(h, [ACCOUNT_READ_SCOPE], user.id);
762
+ const res = await handleAccountRoot(withBearer("/account", token), deps(h));
763
+ expect(res.status).toBe(200);
764
+ const body = (await res.json()) as { id: string; email?: string; door: string };
765
+ expect(body).toEqual({ id: "self", door: "hub" });
766
+ expect(body.email).toBeUndefined();
507
767
  } finally {
508
768
  h.cleanup();
509
769
  }
@@ -1,8 +1,14 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { defaultStartLifecycleOpts, install } from "../commands/install.ts";
6
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
7
+ import {
8
+ PARACHUTE_HUB_ROOT_REDIRECT_ENV,
9
+ getRootRedirect,
10
+ setRootRedirect,
11
+ } from "../hub-settings.ts";
6
12
  import { findService, upsertService } from "../services-manifest.ts";
7
13
 
8
14
  function makeTempPath(): { path: string; configDir: string; cleanup: () => void } {
@@ -408,7 +414,7 @@ describe("install", () => {
408
414
  test("ADOPT-KILLS an attributable same-module orphan on the canonical port + reclaims it (#609)", async () => {
409
415
  // Wipe-recovery: `rm -rf ~/.parachute` + re-`init` leaves the supervised
410
416
  // vault child running on :1940. The fresh install must reclaim the canonical
411
- // port (adopt-kill the attributable orphan) rather than port-walk to 1944.
417
+ // port (adopt-kill the attributable orphan) rather than port-walk to 1945.
412
418
  const { path, configDir, cleanup } = makeTempPath();
413
419
  try {
414
420
  const logs: string[] = [];
@@ -1636,9 +1642,10 @@ describe("install", () => {
1636
1642
  log: (l) => logs.push(l),
1637
1643
  });
1638
1644
  expect(code).toBe(0);
1639
- // First reservation slot is 1944.
1645
+ // First reservation slot is now 1945 — 1944 is parachute-app's
1646
+ // canonical (assigned, non-walkable) slot as of hub-parity P5.
1640
1647
  const entry = findService("parachute-vault", path);
1641
- expect(entry?.port).toBe(1944);
1648
+ expect(entry?.port).toBe(1945);
1642
1649
  expect(logs.join("\n")).toMatch(/canonical port 1940 is in use/);
1643
1650
  // .env is not touched.
1644
1651
  const envPath = join(configDir, "vault", ".env");
@@ -1940,7 +1947,8 @@ describe("install", () => {
1940
1947
  expect(startCalls).toEqual(["someapp"]);
1941
1948
  // Log lines speak in the canonical short name too. Port comes from
1942
1949
  // assignServicePort (third-party gets the first unassigned canonical
1943
- // slot, currently 1944), not the manifest's port hint.
1950
+ // slot, currently 1945 — 1944 is parachute-app's canonical assigned
1951
+ // slot as of hub-parity P5), not the manifest's port hint.
1944
1952
  const joined = logs.join("\n");
1945
1953
  expect(joined).toMatch(/Seeded services\.json entry for someapp/);
1946
1954
  expect(joined).toMatch(/someapp registered on port \d+/);
@@ -2316,3 +2324,177 @@ describe("hub#573 — install auto-start converges on supervised detection", ()
2316
2324
  expect(opts.log).toBe(log);
2317
2325
  });
2318
2326
  });
2327
+
2328
+ // hub-parity P5 (2026-07-11): `parachute install app` defaults the hub's
2329
+ // bare `/` redirect to the app's front door — SET-IF-UNSET ONLY. Every test
2330
+ // here injects `rootRedirectDb` (a hub.db opened in the same disposable
2331
+ // tempdir `makeTempPath()` already isolates) so no real
2332
+ // `~/.parachute/hub.db` is ever touched — never drive this against the
2333
+ // operator's live install.
2334
+ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
2335
+ // The write gates on `resolveRootRedirectDetailed(db).source === "default"`,
2336
+ // which consults `PARACHUTE_HUB_ROOT_REDIRECT`. Neutralize any ambient value
2337
+ // (Aaron's box, CI) so the default-tier tests are deterministic; the
2338
+ // env-set test below manages its own value inside its own body.
2339
+ let savedEnv: string | undefined;
2340
+ beforeEach(() => {
2341
+ savedEnv = process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2342
+ delete process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2343
+ });
2344
+ afterEach(() => {
2345
+ if (savedEnv === undefined) {
2346
+ delete process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2347
+ } else {
2348
+ process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV] = savedEnv;
2349
+ }
2350
+ });
2351
+
2352
+ test("fresh install (no prior root_redirect) sets it to /app/", async () => {
2353
+ const { path, configDir, cleanup } = makeTempPath();
2354
+ try {
2355
+ const db = openHubDb(hubDbPath(configDir));
2356
+ const logs: string[] = [];
2357
+ try {
2358
+ const code = await install("app", {
2359
+ runner: async () => 0,
2360
+ manifestPath: path,
2361
+ configDir,
2362
+ startService: async () => 0,
2363
+ isLinked: () => false,
2364
+ portProbe: async () => false,
2365
+ rootRedirectDb: db,
2366
+ log: (l) => logs.push(l),
2367
+ });
2368
+ expect(code).toBe(0);
2369
+ expect(getRootRedirect(db)).toBe("/app/");
2370
+ expect(logs.join("\n")).toMatch(/front page.*now opens the app at \/app\//);
2371
+ } finally {
2372
+ db.close();
2373
+ }
2374
+ } finally {
2375
+ cleanup();
2376
+ }
2377
+ });
2378
+
2379
+ test("a pre-set root_redirect is left alone — never clobbered", async () => {
2380
+ const { path, configDir, cleanup } = makeTempPath();
2381
+ try {
2382
+ const db = openHubDb(hubDbPath(configDir));
2383
+ const logs: string[] = [];
2384
+ try {
2385
+ setRootRedirect(db, "/surface/reading-room");
2386
+ const code = await install("app", {
2387
+ runner: async () => 0,
2388
+ manifestPath: path,
2389
+ configDir,
2390
+ startService: async () => 0,
2391
+ isLinked: () => false,
2392
+ portProbe: async () => false,
2393
+ rootRedirectDb: db,
2394
+ log: (l) => logs.push(l),
2395
+ });
2396
+ expect(code).toBe(0);
2397
+ // The operator's prior choice survives byte-for-byte.
2398
+ expect(getRootRedirect(db)).toBe("/surface/reading-room");
2399
+ // The set-if-unset write's OWN log line (distinct from the static
2400
+ // postInstallFooter boilerplate, which always prints "now opens the
2401
+ // app too, unless you've already..." regardless of whether the write
2402
+ // fired) must NOT appear — the write itself was skipped.
2403
+ expect(logs.join("\n")).not.toMatch(/now opens the app at \/app\//);
2404
+ } finally {
2405
+ db.close();
2406
+ }
2407
+ } finally {
2408
+ cleanup();
2409
+ }
2410
+ });
2411
+
2412
+ test("installing a different module never touches root_redirect", async () => {
2413
+ const { path, configDir, cleanup } = makeTempPath();
2414
+ try {
2415
+ const db = openHubDb(hubDbPath(configDir));
2416
+ try {
2417
+ const code = await install("notes", {
2418
+ runner: async () => 0,
2419
+ manifestPath: path,
2420
+ configDir,
2421
+ startService: async () => 0,
2422
+ isLinked: () => false,
2423
+ portProbe: async () => false,
2424
+ rootRedirectDb: db,
2425
+ log: () => {},
2426
+ });
2427
+ expect(code).toBe(0);
2428
+ expect(getRootRedirect(db)).toBeNull();
2429
+ } finally {
2430
+ db.close();
2431
+ }
2432
+ } finally {
2433
+ cleanup();
2434
+ }
2435
+ });
2436
+
2437
+ test("an ENV-configured redirect (no DB row) is left alone — never clobbered (N1)", async () => {
2438
+ // Container deploys pin the landing page via PARACHUTE_HUB_ROOT_REDIRECT,
2439
+ // not a DB row. Gating on `getRootRedirect(db) === null` (DB only) would
2440
+ // miss this and write `/app/`, which — since the DB row wins over env on
2441
+ // read — silently overrides their configured landing. The fix gates on
2442
+ // `resolveRootRedirectDetailed(db).source === "default"`, which sees the
2443
+ // env tier. So: env set → no DB row written.
2444
+ const { path, configDir, cleanup } = makeTempPath();
2445
+ const prevEnv = process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2446
+ try {
2447
+ process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV] = "/surface/team-room";
2448
+ const db = openHubDb(hubDbPath(configDir));
2449
+ const logs: string[] = [];
2450
+ try {
2451
+ const code = await install("app", {
2452
+ runner: async () => 0,
2453
+ manifestPath: path,
2454
+ configDir,
2455
+ startService: async () => 0,
2456
+ isLinked: () => false,
2457
+ portProbe: async () => false,
2458
+ rootRedirectDb: db,
2459
+ log: (l) => logs.push(l),
2460
+ });
2461
+ expect(code).toBe(0);
2462
+ // No DB row written — the env-configured landing survives.
2463
+ expect(getRootRedirect(db)).toBeNull();
2464
+ expect(logs.join("\n")).not.toMatch(/now opens the app at \/app\//);
2465
+ } finally {
2466
+ db.close();
2467
+ }
2468
+ } finally {
2469
+ if (prevEnv === undefined) {
2470
+ delete process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2471
+ } else {
2472
+ process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV] = prevEnv;
2473
+ }
2474
+ cleanup();
2475
+ }
2476
+ });
2477
+
2478
+ test("production gate: without rootRedirectDb + a tempdir manifestPath, the write is skipped (not attempted)", async () => {
2479
+ // Mirrors the existing `guidanceProbeAllowed` discriminant elsewhere in
2480
+ // install.ts: a test with a tempdir manifestPath and no explicit opt-in
2481
+ // must never open the real ~/.parachute/hub.db. There's nothing to open
2482
+ // here at all — the assertion is simply that install still completes
2483
+ // cleanly with the DB-touching branch skipped.
2484
+ const { path, configDir, cleanup } = makeTempPath();
2485
+ try {
2486
+ const code = await install("app", {
2487
+ runner: async () => 0,
2488
+ manifestPath: path,
2489
+ configDir,
2490
+ startService: async () => 0,
2491
+ isLinked: () => false,
2492
+ portProbe: async () => false,
2493
+ log: () => {},
2494
+ });
2495
+ expect(code).toBe(0);
2496
+ } finally {
2497
+ cleanup();
2498
+ }
2499
+ });
2500
+ });