@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.3

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.5.14-rc.2",
3
+ "version": "0.5.14-rc.3",
4
4
  "description": "parachute \u2014 the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -23,6 +23,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
23
23
  import { tmpdir } from "node:os";
24
24
  import { join } from "node:path";
25
25
  import { decodeJwt } from "jose";
26
+ import type { CuratedModuleShort } from "../api-modules.ts";
26
27
  import {
27
28
  API_MODULES_CONFIG_REQUIRED_SCOPE,
28
29
  MODULE_CONFIG_PROXY_CLIENT_ID,
@@ -152,14 +153,19 @@ describe("parseModulesConfigPath", () => {
152
153
  });
153
154
  });
154
155
 
155
- test("matches vault and notes (curated modules)", () => {
156
+ test("matches vault and scribe (curated modules)", () => {
156
157
  expect(parseModulesConfigPath("/api/modules/vault/config")?.short).toBe("vault");
157
- expect(parseModulesConfigPath("/api/modules/notes/config/schema")?.short).toBe("notes");
158
+ expect(parseModulesConfigPath("/api/modules/scribe/config/schema")?.short).toBe("scribe");
158
159
  });
159
160
 
160
161
  test("rejects unknown short (non-curated)", () => {
161
162
  expect(parseModulesConfigPath("/api/modules/unknown/config")).toBeUndefined();
162
163
  expect(parseModulesConfigPath("/api/modules/channel/config")).toBeUndefined();
164
+ // Curated list trimmed 2026-05-27: notes / runner / surface are no
165
+ // longer curated and reject at the parse boundary.
166
+ expect(parseModulesConfigPath("/api/modules/notes/config")).toBeUndefined();
167
+ expect(parseModulesConfigPath("/api/modules/runner/config")).toBeUndefined();
168
+ expect(parseModulesConfigPath("/api/modules/surface/config")).toBeUndefined();
163
169
  });
164
170
 
165
171
  test("rejects non-config suffix shapes", () => {
@@ -302,7 +308,7 @@ describe("handleApiModulesConfig — FALLBACK retirement (hub#310)", () => {
302
308
  makeReq("/api/modules/runner/config/schema", {
303
309
  headers: { authorization: `Bearer ${bearer}` },
304
310
  }),
305
- { short: "runner", suffix: "schema" },
311
+ { short: "runner" as CuratedModuleShort, suffix: "schema" },
306
312
  { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
307
313
  );
308
314
  expect(res.status).toBe(404);
@@ -365,7 +371,7 @@ describe("handleApiModulesConfig — FALLBACK retirement (hub#310)", () => {
365
371
  makeReq("/api/modules/runner/config/schema", {
366
372
  headers: { authorization: `Bearer ${bearer}` },
367
373
  }),
368
- { short: "runner", suffix: "schema" },
374
+ { short: "runner" as CuratedModuleShort, suffix: "schema" },
369
375
  {
370
376
  db: h.db,
371
377
  issuer: ISSUER,
@@ -614,7 +620,7 @@ describe("handleApiModulesConfig — stripPrefix=false (notes-shape)", () => {
614
620
  makeReq("/api/modules/notes/config/schema", {
615
621
  headers: { authorization: `Bearer ${bearer}` },
616
622
  }),
617
- { short: "notes", suffix: "schema" },
623
+ { short: "notes" as CuratedModuleShort, suffix: "schema" },
618
624
  {
619
625
  db: h.db,
620
626
  issuer: ISSUER,
@@ -668,7 +674,7 @@ describe("handleApiModulesConfig — hostsBareParachute (hub#307)", () => {
668
674
  makeReq("/api/modules/runner/config/schema", {
669
675
  headers: { authorization: `Bearer ${bearer}` },
670
676
  }),
671
- { short: "runner", suffix: "schema" },
677
+ { short: "runner" as CuratedModuleShort, suffix: "schema" },
672
678
  {
673
679
  db: h.db,
674
680
  issuer: ISSUER,
@@ -700,7 +706,7 @@ describe("handleApiModulesConfig — hostsBareParachute (hub#307)", () => {
700
706
  makeReq("/api/modules/runner/config", {
701
707
  headers: { authorization: `Bearer ${bearer}` },
702
708
  }),
703
- { short: "runner", suffix: "" },
709
+ { short: "runner" as CuratedModuleShort, suffix: "" },
704
710
  {
705
711
  db: h.db,
706
712
  issuer: ISSUER,
@@ -733,7 +739,7 @@ describe("handleApiModulesConfig — hostsBareParachute (hub#307)", () => {
733
739
  },
734
740
  body: JSON.stringify({ intervalSeconds: 120 }),
735
741
  }),
736
- { short: "runner", suffix: "" },
742
+ { short: "runner" as CuratedModuleShort, suffix: "" },
737
743
  {
738
744
  db: h.db,
739
745
  issuer: ISSUER,
@@ -760,7 +766,7 @@ describe("handleApiModulesConfig — hostsBareParachute (hub#307)", () => {
760
766
  makeReq("/api/modules/runner/config", {
761
767
  headers: { authorization: `Bearer ${bearer}` },
762
768
  }),
763
- { short: "runner", suffix: "" },
769
+ { short: "runner" as CuratedModuleShort, suffix: "" },
764
770
  {
765
771
  db: h.db,
766
772
  issuer: ISSUER,
@@ -863,7 +869,7 @@ describe("handleApiModulesConfig — hostsBareParachute (hub#307)", () => {
863
869
  makeReq("/api/modules/runner/config/schema", {
864
870
  headers: { authorization: `Bearer ${bearer}` },
865
871
  }),
866
- { short: "runner", suffix: "schema" },
872
+ { short: "runner" as CuratedModuleShort, suffix: "schema" },
867
873
  {
868
874
  db: h.db,
869
875
  issuer: ISSUER,
@@ -149,10 +149,12 @@ describe("GET /api/modules", () => {
149
149
 
150
150
  test("200 + curated list on fresh container (empty services.json)", async () => {
151
151
  // The v0.6 hot path: brand-new Render container, no services.json
152
- // yet. UI must render "install vault / app / notes / scribe / runner"
153
- // cards even though nothing's installed. hub#323 inserted `app` between
154
- // `vault` and `notes` app auto-bootstraps notes-ui as a sub-unit;
155
- // `notes` (notes-daemon) stays curated for back-compat install paths.
152
+ // yet. UI must render "install vault / scribe" cards even though
153
+ // nothing's installed. Trimmed 2026-05-27 (Aaron-directed launch
154
+ // focus): notes (notes-daemon), surface (host module), and runner
155
+ // (experimental) are no longer curated notes.parachute.computer
156
+ // is the hosted PWA, surface-client is the library for custom UI
157
+ // builders, and runner isn't in the launch focus set.
156
158
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
157
159
  const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
158
160
  db: h.db,
@@ -170,8 +172,10 @@ describe("GET /api/modules", () => {
170
172
  }>;
171
173
  supervisor_available: boolean;
172
174
  };
173
- // Curated order is preserved: vault → app notes scribe → runner.
174
- expect(body.modules.map((m) => m.short)).toEqual(["vault", "surface", "notes", "scribe", "runner"]);
175
+ // Curated order is preserved: vault → scribe (vault first per the
176
+ // recommended install order — the wizard's vault step already runs
177
+ // before this catalog surfaces).
178
+ expect(body.modules.map((m) => m.short)).toEqual(["vault", "scribe"]);
175
179
  expect(body.modules.every((m) => m.available)).toBe(true);
176
180
  expect(body.modules.every((m) => !m.installed)).toBe(true);
177
181
  expect(body.modules.every((m) => m.latest_version === "0.9.9")).toBe(true);
@@ -179,18 +183,25 @@ describe("GET /api/modules", () => {
179
183
  expect(body.supervisor_available).toBe(false);
180
184
  });
181
185
 
182
- test("app row carries package + display props from KNOWN_MODULES (#323)", async () => {
183
- // hub#323 added app to CURATED_MODULES + KNOWN_MODULES so the admin SPA
184
- // install catalog + setup-wizard install tile surface it. Spot-check the
185
- // wire shape resolves app-specific fields (package, displayName, tagline)
186
- // from KNOWN_MODULES rather than a stale default — same shape as the
187
- // runner row test below.
186
+ test("scribe row carries package + display props from KNOWN_MODULES", async () => {
187
+ // Spot-check the wire shape resolves scribe-specific fields
188
+ // (package, displayName, tagline) from KNOWN_MODULES rather than a
189
+ // stale default. Vault is exercised via the install-state test below;
190
+ // this pins the other curated row's KNOWN_MODULES round-trip.
191
+ //
192
+ // Pre-2026-05-27 this test pinned the `surface` row (added by
193
+ // hub#323), and a sibling pinned the `runner` FIRST_PARTY_FALLBACKS
194
+ // row (hub#305). Both modules retired from CURATED_MODULES — the
195
+ // FIRST_PARTY_FALLBACKS / KNOWN_MODULES entries persist for the
196
+ // install-bootstrap path but `/api/modules` doesn't return them.
197
+ // The "uncurated modules don't surface here" test below pins that
198
+ // boundary.
188
199
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
189
200
  const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
190
201
  db: h.db,
191
202
  issuer: ISSUER,
192
203
  manifestPath: h.manifestPath,
193
- fetchLatestVersion: async () => "0.2.0",
204
+ fetchLatestVersion: async () => "0.4.4",
194
205
  });
195
206
  expect(res.status).toBe(200);
196
207
  const body = (await res.json()) as {
@@ -202,42 +213,41 @@ describe("GET /api/modules", () => {
202
213
  available: boolean;
203
214
  }>;
204
215
  };
205
- const surface = body.modules.find((m) => m.short === "surface");
206
- expect(surface).toBeDefined();
207
- expect(surface?.package).toBe("@openparachute/surface");
208
- expect(surface?.display_name).toBe("Surface");
209
- expect(surface?.tagline).toContain("auto-installs Notes");
210
- expect(surface?.available).toBe(true);
211
- });
212
-
213
- test("runner row carries package + display props from FIRST_PARTY_FALLBACKS (#305)", async () => {
214
- // hub#305 added runner to CURATED_MODULES + FIRST_PARTY_FALLBACKS so
215
- // the admin SPA install catalog surfaces it. Spot-check the wire
216
- // shape resolves the runner-specific fields (package, displayName,
217
- // tagline) from the vendored fallback rather than a stale default.
216
+ const scribe = body.modules.find((m) => m.short === "scribe");
217
+ expect(scribe).toBeDefined();
218
+ expect(scribe?.package).toBe("@openparachute/scribe");
219
+ expect(scribe?.display_name).toBe("Scribe");
220
+ expect(scribe?.tagline).toContain("transcription");
221
+ expect(scribe?.available).toBe(true);
222
+ });
223
+
224
+ test("uncurated modules (notes / runner / surface) are NOT returned by GET /api/modules", async () => {
225
+ // CURATED_MODULES was trimmed 2026-05-27 to [vault, scribe]. The
226
+ // KNOWN_MODULES + FIRST_PARTY_FALLBACKS registries still carry
227
+ // entries for notes / runner (install-bootstrap path), but
228
+ // /api/modules only returns CURATED rows. Pins the boundary so a
229
+ // future re-curation has to be intentional, not a stale registry
230
+ // leak.
218
231
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
219
232
  const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
220
233
  db: h.db,
221
234
  issuer: ISSUER,
222
235
  manifestPath: h.manifestPath,
223
- fetchLatestVersion: async () => "0.1.0",
236
+ fetchLatestVersion: async () => null,
224
237
  });
225
- expect(res.status).toBe(200);
226
- const body = (await res.json()) as {
227
- modules: Array<{
228
- short: string;
229
- package: string;
230
- display_name: string;
231
- tagline: string;
232
- available: boolean;
233
- }>;
234
- };
235
- const runner = body.modules.find((m) => m.short === "runner");
236
- expect(runner).toBeDefined();
237
- expect(runner?.package).toBe("@openparachute/runner");
238
- expect(runner?.display_name).toBe("Runner");
239
- expect(runner?.tagline).toContain("Vault-as-job-substrate");
240
- expect(runner?.available).toBe(true);
238
+ const body = (await res.json()) as { modules: Array<{ short: string }> };
239
+ const shorts = body.modules.map((m) => m.short);
240
+ // Positive shape assertion — stronger than `not.toContain` because
241
+ // it also catches "we accidentally added a new uncurated entry"
242
+ // and "we accidentally removed an existing curated entry." Update
243
+ // this assertion intentionally when CURATED_MODULES changes.
244
+ expect(shorts).toEqual(["vault", "scribe"]);
245
+ // Belt + suspenders: explicit negatives for the modules dropped
246
+ // 2026-05-27, so a developer regressing the curated list sees both
247
+ // the shape failure AND the named-module failure messages.
248
+ expect(shorts).not.toContain("notes");
249
+ expect(shorts).not.toContain("runner");
250
+ expect(shorts).not.toContain("surface");
241
251
  });
242
252
 
243
253
  test("surfaces installed_version from services.json", async () => {
@@ -272,11 +282,11 @@ describe("GET /api/modules", () => {
272
282
  expect(vault?.installed_version).toBe("0.4.5");
273
283
  expect(vault?.latest_version).toBe("0.5.0");
274
284
  expect(vault?.install_dir).toBe("/parachute/modules/node_modules/@openparachute/vault");
275
- // The other curated rows stay installed:false — the test installed
276
- // only vault, so notes + scribe still render as available-but-not-installed.
277
- const notes = body.modules.find((m) => m.short === "notes");
278
- expect(notes?.installed).toBe(false);
279
- expect(notes?.installed_version).toBeNull();
285
+ // The other curated row stays installed:false — the test installed
286
+ // only vault, so scribe still renders as available-but-not-installed.
287
+ const scribe = body.modules.find((m) => m.short === "scribe");
288
+ expect(scribe?.installed).toBe(false);
289
+ expect(scribe?.installed_version).toBeNull();
280
290
  });
281
291
 
282
292
  test("includes supervisor status + pid when a supervisor is injected", async () => {
@@ -309,9 +319,9 @@ describe("GET /api/modules", () => {
309
319
  expect(vault?.pid).toBe(12345);
310
320
  // Modules without a supervisor entry get null status — the UI
311
321
  // disables Restart/Stop for those since there's no live process.
312
- const notes = body.modules.find((m) => m.short === "notes");
313
- expect(notes?.supervisor_status).toBeNull();
314
- expect(notes?.pid).toBeNull();
322
+ const scribe = body.modules.find((m) => m.short === "scribe");
323
+ expect(scribe?.supervisor_status).toBeNull();
324
+ expect(scribe?.pid).toBeNull();
315
325
  expect(body.supervisor_available).toBe(true);
316
326
  });
317
327
 
@@ -366,22 +376,28 @@ describe("GET /api/modules", () => {
366
376
  });
367
377
 
368
378
  test("management_url does not double-prepend mount when managementUrl is already mount-prefixed (hub#380)", async () => {
369
- // Audit caught 2026-05-25: app declares `managementUrl: "/surface/admin/"`
379
+ // Audit caught 2026-05-25: surface declared `managementUrl: "/surface/admin/"`
370
380
  // (full hub-origin path) and `paths: ["/surface", "/.parachute"]`. The
371
- // SPA's Services dropdown was navigating to `/app/surface/admin/` (404)
372
- // because api-modules unconditionally prepended the mount onto the
373
- // candidate. Fix: detect already-mount-prefixed paths and pass through.
381
+ // SPA's Services dropdown was navigating to `/surface/surface/admin/`
382
+ // (404) because api-modules unconditionally prepended the mount onto
383
+ // the candidate. Fix: detect already-mount-prefixed paths and pass
384
+ // through.
374
385
  //
375
386
  // Single-instance modules conventionally declare the full path; only
376
387
  // multi-instance modules (vault) use the per-instance relative form.
388
+ // Post 2026-05-27 CURATED trim the canonical single-instance example
389
+ // is scribe (when scribe ships a managementUrl — scribe#53). For now
390
+ // we exercise the same code path with vault declaring an
391
+ // already-mount-prefixed managementUrl: any module whose declared
392
+ // URL starts with its mount must pass through unchanged.
377
393
  writeManifest(h.manifestPath, [
378
394
  {
379
- name: "parachute-surface",
380
- port: 1946,
381
- paths: ["/surface", "/.parachute"],
382
- health: "/surface/healthz",
383
- version: "0.2.0-rc.13",
384
- installDir: "/install/dir/surface",
395
+ name: "parachute-vault",
396
+ port: 1940,
397
+ paths: ["/vault/default"],
398
+ health: "/vault/default/health",
399
+ version: "0.4.5",
400
+ installDir: "/install/dir/vault",
385
401
  },
386
402
  ]);
387
403
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
@@ -391,17 +407,18 @@ describe("GET /api/modules", () => {
391
407
  manifestPath: h.manifestPath,
392
408
  fetchLatestVersion: async () => null,
393
409
  readModuleManifest: async (installDir) => {
394
- if (installDir === "/install/dir/surface") {
410
+ if (installDir === "/install/dir/vault") {
395
411
  return {
396
- name: "surface",
397
- manifestName: "parachute-surface",
398
- displayName: "Surface",
412
+ name: "parachute-vault",
413
+ manifestName: "parachute-vault",
414
+ displayName: "Vault",
399
415
  tagline: "",
400
- port: 1946,
401
- paths: ["/surface", "/.parachute"],
402
- health: "/surface/healthz",
403
- uiUrl: "/surface/admin/",
404
- managementUrl: "/surface/admin/",
416
+ port: 1940,
417
+ paths: ["/vault/default"],
418
+ health: "/vault/default/health",
419
+ // Already-mount-prefixed managementUrl — must NOT have the
420
+ // mount prepended again.
421
+ managementUrl: "/vault/default/admin/",
405
422
  } as unknown as Awaited<
406
423
  ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
407
424
  >;
@@ -413,9 +430,9 @@ describe("GET /api/modules", () => {
413
430
  const body = (await res.json()) as {
414
431
  modules: Array<{ short: string; management_url: string | null }>;
415
432
  };
416
- const surface = body.modules.find((m) => m.short === "surface");
417
- // Single `/surface/`, not `/surface/surface/`.
418
- expect(surface?.management_url).toBe("/surface/admin/");
433
+ const vault = body.modules.find((m) => m.short === "vault");
434
+ // Single `/vault/default/`, not `/vault/default/vault/default/`.
435
+ expect(vault?.management_url).toBe("/vault/default/admin/");
419
436
  });
420
437
 
421
438
  test("management_url prefix-ish names don't collide (hub#380 — /app vs /app-foo)", async () => {
@@ -786,8 +803,8 @@ describe("GET /api/modules", () => {
786
803
  },
787
804
  ]);
788
805
  // Other curated rows stay empty — uis is per-row, not global.
789
- const notes = body.modules.find((m) => m.short === "notes");
790
- expect(notes?.uis).toEqual([]);
806
+ const scribe = body.modules.find((m) => m.short === "scribe");
807
+ expect(scribe?.uis).toEqual([]);
791
808
  });
792
809
 
793
810
  test("optional fields ride through verbatim, missing fields become null on the wire", async () => {
@@ -2162,6 +2162,9 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2162
2162
  const db = openHubDb(hubDbPath(h.dir));
2163
2163
  try {
2164
2164
  const user = await createUser(db, "owner", "pw");
2165
+ // Seed services.json with `parachute-scribe` so the wizard's scribe
2166
+ // install tile renders the already-installed shape. Post-2026-05-27
2167
+ // CURATED trim scribe is the only non-vault install tile.
2165
2168
  writeManifest(
2166
2169
  {
2167
2170
  services: [
@@ -2172,15 +2175,12 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2172
2175
  paths: ["/vault/default"],
2173
2176
  health: "/health",
2174
2177
  },
2175
- // hub#323: app replaces notes as the wizard's first install tile.
2176
- // Seeding services.json with `parachute-app` exercises the
2177
- // already-installed render path on the wizard's first tile.
2178
2178
  {
2179
- name: "parachute-surface",
2180
- version: "0.2.0",
2181
- port: 1946,
2182
- paths: ["/app", "/.parachute"],
2183
- health: "/surface/healthz",
2179
+ name: "parachute-scribe",
2180
+ version: "0.4.4",
2181
+ port: 1943,
2182
+ paths: ["/scribe"],
2183
+ health: "/scribe/health",
2184
2184
  },
2185
2185
  ],
2186
2186
  },
@@ -2203,13 +2203,16 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2203
2203
  );
2204
2204
  const html = await res.text();
2205
2205
  expect(html).toContain("Already installed");
2206
- expect(html).toContain('action="/admin/setup/install/scribe"');
2206
+ // The scribe tile rendered the installed shape, not the install form.
2207
+ expect(html).not.toContain('action="/admin/setup/install/scribe"');
2208
+ // "Manage in admin" is the secondary link on the already-installed tile.
2209
+ expect(html).toContain("Manage in admin");
2207
2210
  } finally {
2208
2211
  db.close();
2209
2212
  }
2210
2213
  });
2211
2214
 
2212
- test("done screen renders op-poll panel when ?op_surface=<id> matches a registry op", async () => {
2215
+ test("done screen renders op-poll panel when ?op_scribe=<id> matches a registry op", async () => {
2213
2216
  const db = openHubDb(hubDbPath(h.dir));
2214
2217
  try {
2215
2218
  const user = await createUser(db, "owner", "pw");
@@ -2229,15 +2232,16 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2229
2232
  );
2230
2233
  setSetting(db, "setup_expose_mode", "localhost");
2231
2234
  const reg = getDefaultOperationsRegistry();
2232
- // hub#323: op-poll panel rides on the `app` tile now (app is the wizard's
2233
- // first install tile post-Notes-as-app-migration). Same shape as the
2234
- // pre-#324 `op_notes=<id>` flow.
2235
- const op = reg.create("install", "app");
2236
- reg.update(op.id, { status: "running" }, "running bun add -g @openparachute/app@latest");
2235
+ // Post-2026-05-27 CURATED trim, scribe is the only non-vault wizard
2236
+ // install tile, so it carries the op-poll panel. Same shape as the
2237
+ // prior `op_app=<id>` / `op_notes=<id>` flows — the rendering code
2238
+ // is per-`?op_<short>=<id>` query and tile-row agnostic.
2239
+ const op = reg.create("install", "scribe");
2240
+ reg.update(op.id, { status: "running" }, "running bun add -g @openparachute/scribe@latest");
2237
2241
  const { createSession } = await import("../sessions.ts");
2238
2242
  const session = createSession(db, { userId: user.id });
2239
2243
  const res = handleSetupGet(
2240
- req(`/admin/setup?just_finished=1&op_surface=${op.id}`, {
2244
+ req(`/admin/setup?just_finished=1&op_scribe=${op.id}`, {
2241
2245
  headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
2242
2246
  }),
2243
2247
  {
@@ -2293,7 +2297,7 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2293
2297
  return 0;
2294
2298
  };
2295
2299
  const post = await handleSetupInstallPost(
2296
- req("/admin/setup/install/notes", {
2300
+ req("/admin/setup/install/scribe", {
2297
2301
  method: "POST",
2298
2302
  body: new URLSearchParams({ [CSRF_FIELD_NAME]: csrf }).toString(),
2299
2303
  headers: {
@@ -2301,7 +2305,7 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2301
2305
  cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
2302
2306
  },
2303
2307
  }),
2304
- "notes",
2308
+ "scribe",
2305
2309
  {
2306
2310
  db,
2307
2311
  manifestPath: h.manifestPath,
@@ -2315,10 +2319,10 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2315
2319
  );
2316
2320
  expect(post.status).toBe(303);
2317
2321
  const location = post.headers.get("location") ?? "";
2318
- expect(location).toMatch(/^\/admin\/setup\?just_finished=1&op_notes=/);
2322
+ expect(location).toMatch(/^\/admin\/setup\?just_finished=1&op_scribe=/);
2319
2323
  await new Promise((r) => setTimeout(r, 50));
2320
2324
  expect(runCalls.length).toBeGreaterThan(0);
2321
- expect(runCalls[0]?.join(" ")).toContain("bun add -g @openparachute/notes@latest");
2325
+ expect(runCalls[0]?.join(" ")).toContain("bun add -g @openparachute/scribe@latest");
2322
2326
  } finally {
2323
2327
  db.close();
2324
2328
  }
@@ -2405,7 +2409,7 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2405
2409
  });
2406
2410
  const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
2407
2411
  const post = await handleSetupInstallPost(
2408
- req("/admin/setup/install/notes", {
2412
+ req("/admin/setup/install/scribe", {
2409
2413
  method: "POST",
2410
2414
  body: new URLSearchParams({ [CSRF_FIELD_NAME]: csrf }).toString(),
2411
2415
  headers: {
@@ -2413,7 +2417,7 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2413
2417
  cookie: `${CSRF_COOKIE_NAME}=${csrf}`,
2414
2418
  },
2415
2419
  }),
2416
- "notes",
2420
+ "scribe",
2417
2421
  {
2418
2422
  db,
2419
2423
  manifestPath: h.manifestPath,
@@ -3296,7 +3300,14 @@ describe("done screen — 'Start using your vault' tile (hub#342)", () => {
3296
3300
  }
3297
3301
  });
3298
3302
 
3299
- test("when app is also installed, the lead tile links to /surface/notes/", async () => {
3303
+ test("lead tile always points at notes.parachute.computer (canonical hosted PWA) regardless of local module installs", async () => {
3304
+ // Pre-2026-05-27 the lead tile flipped to `/surface/notes/` when the
3305
+ // Surface module was installed locally. Aaron's launch-focus
3306
+ // directive: notes.parachute.computer is the canonical user-facing
3307
+ // UI, and the wizard should always point operators at it (rather
3308
+ // than maybe-or-maybe-not-installed local Surface). This test pins
3309
+ // that the lead tile is invariant under the install state of
3310
+ // uncurated modules.
3300
3311
  const db = openHubDb(hubDbPath(h.dir));
3301
3312
  try {
3302
3313
  const user = await createUser(db, "owner", "pw");
@@ -3310,6 +3321,9 @@ describe("done screen — 'Start using your vault' tile (hub#342)", () => {
3310
3321
  paths: ["/vault/default"],
3311
3322
  health: "/health",
3312
3323
  },
3324
+ // Even with parachute-surface installed locally (an uncurated
3325
+ // module post-trim), the lead tile must NOT flip to a local
3326
+ // path.
3313
3327
  {
3314
3328
  name: "parachute-surface",
3315
3329
  version: "0.2.0",
@@ -3338,15 +3352,27 @@ describe("done screen — 'Start using your vault' tile (hub#342)", () => {
3338
3352
  );
3339
3353
  const html = await res.text();
3340
3354
  expect(html).toContain("Start using your vault");
3341
- // App installed → primary CTA links to Notes-as-UI inside App.
3342
- expect(html).toContain('href="/surface/notes/"');
3355
+ // Lead CTA always targets the hosted PWA.
3356
+ expect(html).toContain("https://notes.parachute.computer/add?url=");
3343
3357
  expect(html).toContain("Open Notes");
3358
+ // The pre-trim local-surface fallback is gone — the lead tile does
3359
+ // NOT link to /surface/notes/ anymore.
3360
+ expect(html).not.toContain('href="/surface/notes/"');
3344
3361
  } finally {
3345
3362
  db.close();
3346
3363
  }
3347
3364
  });
3348
3365
 
3349
- test("succeeded install op renders a 'Use it now' link pointing at the module's surface", async () => {
3366
+ test("succeeded install op renders 'Manage modules' link (no 'Use it now' for modules without a hosted surface)", async () => {
3367
+ // Pre-2026-05-27 the surface module had a USE_IT_NOW_URLS entry
3368
+ // pointing at `/surface/notes/`, so a succeeded surface install tile
3369
+ // rendered a primary "Use it now" link. Post-trim only scribe + vault
3370
+ // are curated; vault has its own lead tile (above the install row);
3371
+ // scribe doesn't ship a user-facing landing surface today
3372
+ // (scribe#53 tracks the eventual admin SPA), so USE_IT_NOW_URLS is
3373
+ // empty and a succeeded scribe install renders only the "Manage
3374
+ // modules" secondary affordance. Future per-module surfaces can
3375
+ // re-add an entry to that map.
3350
3376
  const db = openHubDb(hubDbPath(h.dir));
3351
3377
  try {
3352
3378
  const user = await createUser(db, "owner", "pw");
@@ -3366,12 +3392,12 @@ describe("done screen — 'Start using your vault' tile (hub#342)", () => {
3366
3392
  );
3367
3393
  setSetting(db, "setup_expose_mode", "localhost");
3368
3394
  const reg = getDefaultOperationsRegistry();
3369
- const op = reg.create("install", "app");
3370
- reg.update(op.id, { status: "succeeded" }, "installed @openparachute/app");
3395
+ const op = reg.create("install", "scribe");
3396
+ reg.update(op.id, { status: "succeeded" }, "installed @openparachute/scribe");
3371
3397
  const { createSession } = await import("../sessions.ts");
3372
3398
  const session = createSession(db, { userId: user.id });
3373
3399
  const res = handleSetupGet(
3374
- req(`/admin/setup?just_finished=1&op_surface=${op.id}`, {
3400
+ req(`/admin/setup?just_finished=1&op_scribe=${op.id}`, {
3375
3401
  headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
3376
3402
  }),
3377
3403
  {
@@ -3384,17 +3410,24 @@ describe("done screen — 'Start using your vault' tile (hub#342)", () => {
3384
3410
  );
3385
3411
  const html = await res.text();
3386
3412
  expect(html).toContain("status: succeeded");
3387
- // Primary "Use it now" link goes to the app's surface; secondary
3388
- // "Manage modules" link still present.
3389
- expect(html).toContain(">Use it now<");
3390
- expect(html).toContain('href="/surface/notes/"');
3413
+ // No "Use it now" scribe has no entry in USE_IT_NOW_URLS today.
3414
+ expect(html).not.toContain(">Use it now<");
3415
+ // "Manage modules" secondary link is always present on a terminal-
3416
+ // succeeded install tile.
3391
3417
  expect(html).toContain(">Manage modules<");
3392
3418
  } finally {
3393
3419
  db.close();
3394
3420
  }
3395
3421
  });
3396
3422
 
3397
- test("'Already installed' tile gains a 'Use it now' link too", async () => {
3423
+ test("'Already installed' tile renders without a 'Use it now' link when the module has no hosted surface", async () => {
3424
+ // Post-2026-05-27 CURATED trim, USE_IT_NOW_URLS is empty (scribe has
3425
+ // no first-class user-facing landing surface yet; vault gets its
3426
+ // own lead tile, not an install tile). The already-installed tile
3427
+ // therefore renders only the "Manage in admin" secondary link. Pre-
3428
+ // trim the surface module had a USE_IT_NOW_URLS entry that drove
3429
+ // this surface, so the test now pins the absence rather than the
3430
+ // presence.
3398
3431
  const db = openHubDb(hubDbPath(h.dir));
3399
3432
  try {
3400
3433
  const user = await createUser(db, "owner", "pw");
@@ -3409,11 +3442,11 @@ describe("done screen — 'Start using your vault' tile (hub#342)", () => {
3409
3442
  health: "/health",
3410
3443
  },
3411
3444
  {
3412
- name: "parachute-surface",
3413
- version: "0.2.0",
3414
- port: 1946,
3415
- paths: ["/surface"],
3416
- health: "/surface/healthz",
3445
+ name: "parachute-scribe",
3446
+ version: "0.4.4",
3447
+ port: 1943,
3448
+ paths: ["/scribe"],
3449
+ health: "/scribe/health",
3417
3450
  },
3418
3451
  ],
3419
3452
  },
@@ -3436,8 +3469,10 @@ describe("done screen — 'Start using your vault' tile (hub#342)", () => {
3436
3469
  );
3437
3470
  const html = await res.text();
3438
3471
  expect(html).toContain("Already installed");
3439
- // App's already-installed tile carries the Use it now link.
3440
- expect(html).toContain('href="/surface/notes/"');
3472
+ // No "Use it now" on the scribe already-installed tile.
3473
+ expect(html).not.toContain(">Use it now<");
3474
+ // Secondary affordance still present.
3475
+ expect(html).toContain("Manage in admin");
3441
3476
  } finally {
3442
3477
  db.close();
3443
3478
  }
@@ -3,10 +3,12 @@
3
3
  *
4
4
  * Combines three sources into a single per-module row:
5
5
  *
6
- * - **Curated availability** — vault, notes, scribe, runner (the v0.6
7
- * release bar). The Phase-2 marketplace will broaden this; for now
8
- * it's hardcoded so the admin UI has a stable "what can I install?"
9
- * list even on a fresh container where services.json is empty.
6
+ * - **Curated availability** — vault, scribe (the launch focus per
7
+ * Aaron 2026-05-27). The list was previously broader; trimmed for
8
+ * the launch arc. The Phase-2 marketplace will broaden this; for
9
+ * now it's hardcoded so the admin UI has a stable "what can I
10
+ * install?" list even on a fresh container where services.json is
11
+ * empty.
10
12
  * - **Installed state** — services.json reads (version, installDir).
11
13
  * - **Supervisor state** — per-module run status (`running` / `stopped`
12
14
  * / `crashed` / `starting` / `restarting`) + pid. Absent when the
@@ -80,15 +82,30 @@ function lookupModule(
80
82
  export const API_MODULES_REQUIRED_SCOPE = "parachute:host:auth";
81
83
 
82
84
  /**
83
- * Curated module short-names for v0.6 Render self-host. Marketplace is
84
- * Phase 2 until then, the admin UI offers exactly these. Order is the
85
- * recommended install order (vault → app → notes → scribe → runner;
86
- * app auto-bootstraps notes-ui on first boot — `notes` here is the
87
- * notes-daemon back-compat install path retained for operators still on
88
- * the pre-app architecture; scribe + runner come last because they
89
- * depend on a working vault + app to be useful).
85
+ * Curated module short-names. The admin UI offers exactly these for install
86
+ * + management. Order is the recommended install order (vault first, scribe
87
+ * second).
88
+ *
89
+ * Trimmed 2026-05-27 (Aaron-directed launch focus) from the prior set of
90
+ * `["vault", "surface", "notes", "scribe", "runner"]`. The dropped modules
91
+ * are still published on npm and still work they're just not the focus:
92
+ *
93
+ * - `notes` (notes-daemon): retired. Notes-UI now lives at
94
+ * `notes.parachute.computer` as a hosted SPA — operators don't install
95
+ * a notes daemon anymore. The npm package `@openparachute/notes-ui`
96
+ * is a library imported by `parachute-surface` and by custom-surface
97
+ * builders.
98
+ * - `surface` (host module): de-emphasized. `@openparachute/surface-client`
99
+ * remains the canonical library for folks building their own UIs
100
+ * against a Parachute hub; running the surface-host module on your
101
+ * own box is no longer the headline path (use notes.parachute.computer
102
+ * or build your own).
103
+ * - `runner`: experimental, not in the focus set for launch.
104
+ *
105
+ * Re-adding any of these is one line — keep the list small until use
106
+ * cases demand otherwise.
90
107
  */
91
- export const CURATED_MODULES = ["vault", "surface", "notes", "scribe", "runner"] as const;
108
+ export const CURATED_MODULES = ["vault", "scribe"] as const;
92
109
  export type CuratedModuleShort = (typeof CURATED_MODULES)[number];
93
110
 
94
111
  export interface ApiModulesDeps {
@@ -889,27 +889,15 @@ export interface RenderDoneStepProps {
889
889
  * shape.
890
890
  */
891
891
  installTiles?: readonly ModuleInstallTileState[];
892
- /**
893
- * Whether parachute-app is installed alongside the vault. Drives the
894
- * "Start using your vault" lead tile (hub#342): when true, the tile
895
- * links to `/surface/notes/` (the canonical user-facing surface — App
896
- * auto-bootstraps Notes-as-UI per the 2026-05-21 migration). When
897
- * false, it falls back to the vault's own admin UI at
898
- * `/vault/<name>/admin/` so the operator still has a single obvious
899
- * "start using parachute" target. Omitted = back-compat with tests
900
- * that render the done step without dependency-checking; defaults to
901
- * false (vault-admin fallback).
902
- */
903
- appInstalled?: boolean;
904
892
  }
905
893
 
906
894
  export function renderDoneStep(props: RenderDoneStepProps): string {
907
- const { vaultName, hubOrigin, exposeMode, mintedToken, installTiles, appInstalled } = props;
895
+ const { vaultName, hubOrigin, exposeMode, mintedToken, installTiles } = props;
908
896
  const reachable = exposeMode ? renderReachableTile(exposeMode, hubOrigin) : "";
909
897
  const mcpTile = renderMcpTile(vaultName, hubOrigin, mintedToken);
910
898
  const tiles = installTiles && installTiles.length > 0 ? installTiles : [];
911
899
  const installSection = tiles.length > 0 ? renderInstallTiles(tiles) : "";
912
- const startTile = renderStartUsingTile(vaultName, appInstalled === true, hubOrigin);
900
+ const startTile = renderStartUsingTile(vaultName, hubOrigin);
913
901
  // The done-grid hosts the MCP-connect tile + the admin-UI fallback.
914
902
  // The install tiles sit above it as a "what's next?" surface (curated
915
903
  // catalog of modules an operator might want next). The "Start using
@@ -1104,7 +1092,8 @@ function renderMcpTile(
1104
1092
  }
1105
1093
 
1106
1094
  /**
1107
- * The "Start using your vault" lead tile on the done step (hub#342).
1095
+ * The "Start using your vault" lead tile on the done step (hub#342,
1096
+ * Aaron 2026-05-27 simplification).
1108
1097
  *
1109
1098
  * Closes Aaron's "no clear way to go from setting up parachute to
1110
1099
  * actually using parachute" friction. Sits above the MCP / install
@@ -1112,48 +1101,20 @@ function renderMcpTile(
1112
1101
  * everything else on the done screen is operator-flavored (MCP
1113
1102
  * command, admin UI, additional module installs).
1114
1103
  *
1115
- * Two shapes:
1116
- * - **App installed** primary tile targets `/surface/notes/` (the
1117
- * Notes app reading the just-created vault). This is the
1118
- * canonical surface post-Notes-as-app migration (parachute-app §17).
1119
- * - **App NOT installed** → primary tile targets the vault's own
1120
- * admin UI at `/vault/<name>/admin/`. The copy explains that
1121
- * installing App + Notes is the recommended next step for a
1122
- * content-browsing surface, and points at the install tile below.
1123
- *
1124
- * Either way, the operator has ONE obvious click target that says
1125
- * "start using parachute" — not three competing tiles where the
1126
- * "real" entry point is buried under the MCP command pre-hub#342.
1127
- */
1128
- /**
1129
- * Lead "Start using your vault" tile. Points at the canonical
1130
- * notes.parachute.computer hosted PWA as the primary CTA — with the
1131
- * operator's own hub URL pre-filled via `?url=` so the connect screen
1132
- * auto-populates + auto-focuses (notes-ui AddVault route, see
1133
- * parachute-app/packages/notes-ui/src/app/routes/AddVault.tsx).
1134
- *
1135
- * Aaron 2026-05-27 directive: "skipping the local surface install for
1136
- * most operators is good ... showing notes.parachute.computer more
1137
- * prominently is a good idea." The notes.parachute.computer PWA is the
1138
- * canonical user-facing UI; operators no longer need to install the
1139
- * Surface module locally to use Notes. They still can (local install
1140
- * works the same way), but the wizard doesn't push them toward it as
1141
- * the default.
1142
- *
1104
+ * Points at the canonical notes.parachute.computer hosted PWA as the
1105
+ * primary CTA with the operator's own hub URL pre-filled via
1106
+ * `?url=` so the connect screen auto-populates + auto-focuses
1107
+ * (notes-ui AddVault route, see
1108
+ * parachute-surface/packages/notes-ui/src/app/routes/AddVault.tsx).
1143
1109
  * Secondary CTA: "Open vault admin" (the vault's own admin UI on this
1144
1110
  * hub) for operators who want to look at raw vault state.
1145
1111
  *
1146
- * `appInstalled` is no longer load-bearing for the primary path —
1147
- * notes.parachute.computer works regardless of whether Surface is
1148
- * installed locally. Kept in the signature so the older test fixtures
1149
- * + the boolean flag stay coherent; only the secondary fallback message
1150
- * differs based on it.
1112
+ * Previously varied by whether `parachute-surface` was installed
1113
+ * locally pointing at `/surface/notes/` in that case. Dropped
1114
+ * 2026-05-27: hub+vault+scribe is the focus; notes.parachute.computer
1115
+ * is canonical regardless of local surface install state.
1151
1116
  */
1152
- function renderStartUsingTile(
1153
- vaultName: string,
1154
- appInstalled: boolean,
1155
- hubOrigin: string,
1156
- ): string {
1117
+ function renderStartUsingTile(vaultName: string, hubOrigin: string): string {
1157
1118
  const safeVault = escapeHtml(vaultName);
1158
1119
  // Vault names pass `/^[a-z0-9][a-z0-9-]*$/i` so URL-encoding is mostly
1159
1120
  // a no-op today, but use encodeURIComponent defensively to match hub.ts:505.
@@ -1164,14 +1125,6 @@ function renderStartUsingTile(
1164
1125
  const vaultUrlForAdd = encodeURIComponent(
1165
1126
  `${hubOrigin.replace(/\/+$/, "")}/vault/${vaultName}`,
1166
1127
  );
1167
- // For appInstalled=false case (Surface NOT installed locally),
1168
- // notes.parachute.computer is the recommended path. For appInstalled=true,
1169
- // we mention the local option as a secondary affordance.
1170
- const localNotesFallback = appInstalled
1171
- ? `<p class="start-using-secondary">
1172
- <a href="/surface/notes/">Or use Notes installed locally on this hub →</a>
1173
- </p>`
1174
- : "";
1175
1128
  return `<section class="start-using" data-testid="start-using-tile">
1176
1129
  <h2>Start using your vault</h2>
1177
1130
  <p>Open Notes — the canonical browser UI for your vault <code>${safeVault}</code>.
@@ -1180,7 +1133,6 @@ function renderStartUsingTile(
1180
1133
  <p class="start-using-secondary">
1181
1134
  <a href="/vault/${urlVault}/admin/">Or browse the vault's admin UI →</a>
1182
1135
  </p>
1183
- ${localNotesFallback}
1184
1136
  </section>`;
1185
1137
  }
1186
1138
 
@@ -1323,13 +1275,12 @@ function renderInstallTile(tile: ModuleInstallTileState): string {
1323
1275
  * surface decision.
1324
1276
  */
1325
1277
  const USE_IT_NOW_URLS: Partial<Record<CuratedModuleShort, string>> = {
1326
- surface: "/surface/notes/",
1327
- notes: "/notes/",
1328
- // Omitted: scribe + runner. They don't ship an admin SPA yet
1329
- // (scribe#53, runner#8 track). Pointing "Use it now" at /scribe/admin
1330
- // or /runner/admin today would 404 better to fall through to the
1331
- // "Manage modules" link than to send the operator into a dead end.
1332
- // Add the entry here once those modules ship their admin UI.
1278
+ // Empty: vault has its own lead "Start using" tile (the
1279
+ // notes.parachute.computer CTA), so it doesn't appear here. Scribe
1280
+ // doesn't ship an admin SPA at /scribe/admin/ that's useful for
1281
+ // first-time operators (the page exists but it's config-management;
1282
+ // not "use it"). Re-add per-module entries here if/when a module
1283
+ // ships a user-facing landing surface worth pointing at.
1333
1284
  };
1334
1285
 
1335
1286
  /**
@@ -1482,16 +1433,18 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1482
1433
  // Module install tiles (hub#272 Item B). One per curated module
1483
1434
  // other than vault (which the wizard already provisioned).
1484
1435
  const installTiles = buildInstallTiles(url, deps);
1485
- // hub#342: drive the lead "Start using your vault" tile's target.
1486
- // When parachute-app is installed alongside vault, the tile links
1487
- // to `/surface/notes/` (auto-bootstrapped Notes-as-UI per parachute-app
1488
- // §17). Otherwise it falls back to the vault's own admin UI.
1489
- const appInstalled = isModuleInstalled("surface", deps.manifestPath);
1436
+ // The lead "Start using your vault" tile points at
1437
+ // notes.parachute.computer/add always, regardless of any
1438
+ // local module install state. Prior versions of this code
1439
+ // checked `isModuleInstalled("surface", ...)` to switch to a
1440
+ // local `/surface/notes/` link, but the launch focus is
1441
+ // hub+vault+scribe and notes.parachute.computer is the
1442
+ // canonical Notes UI (Aaron-directed 2026-05-27). Dropped the
1443
+ // local-fallback branch.
1490
1444
  const doneProps: RenderDoneStepProps = {
1491
1445
  vaultName,
1492
1446
  hubOrigin: deps.issuer,
1493
1447
  installTiles,
1494
- appInstalled,
1495
1448
  };
1496
1449
  if (exposeMode !== undefined) doneProps.exposeMode = exposeMode;
1497
1450
  if (mintedToken) doneProps.mintedToken = mintedToken;
@@ -2152,11 +2105,6 @@ const INSTALL_TILE_PROPS: ReadonlyArray<{
2152
2105
  displayName: string;
2153
2106
  tagline: string;
2154
2107
  }> = [
2155
- {
2156
- short: "surface",
2157
- displayName: "Surface",
2158
- tagline: "Host module for Parachute surfaces — auto-installs Notes on first boot.",
2159
- },
2160
2108
  {
2161
2109
  short: "scribe",
2162
2110
  displayName: "Scribe",
@@ -2323,11 +2271,10 @@ function validateAccountFields(input: {
2323
2271
 
2324
2272
  /**
2325
2273
  * Whether a given curated module is currently installed (has a row in
2326
- * services.json keyed by its canonical `manifestName`). Used by the
2327
- * done-step renderer (hub#342) to decide whether to point the "Start
2328
- * using your vault" tile at `/surface/notes/` (App installed Notes UI
2329
- * auto-bootstrapped) vs the vault's own admin UI. Cheap manifest read
2330
- * shared with `buildInstallTiles`.
2274
+ * services.json keyed by its canonical `manifestName`). Used by
2275
+ * `buildInstallTiles` to decide whether an install-tile row renders
2276
+ * the "install" form or the "already installed" state. Cheap manifest
2277
+ * read (no network).
2331
2278
  */
2332
2279
  function isModuleInstalled(short: CuratedModuleShort, manifestPath: string): boolean {
2333
2280
  const manifest = readManifestLenient(manifestPath);