@openparachute/hub 0.7.7-rc.8 → 0.7.7

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.
Files changed (80) hide show
  1. package/README.md +7 -7
  2. package/package.json +4 -12
  3. package/src/__tests__/account-api.test.ts +29 -2
  4. package/src/__tests__/account-session.test.ts +9 -1
  5. package/src/__tests__/account-token.test.ts +26 -2
  6. package/src/__tests__/admin-connections-credentials.test.ts +41 -0
  7. package/src/__tests__/admin-lock.test.ts +3 -14
  8. package/src/__tests__/admin-module-token.test.ts +10 -30
  9. package/src/__tests__/admin-surfaces.test.ts +21 -0
  10. package/src/__tests__/api-hub-upgrade.test.ts +11 -0
  11. package/src/__tests__/api-mint-token.test.ts +25 -0
  12. package/src/__tests__/api-modules-ops.test.ts +34 -29
  13. package/src/__tests__/api-modules.test.ts +50 -58
  14. package/src/__tests__/api-revoke-token.test.ts +23 -0
  15. package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
  16. package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
  17. package/src/__tests__/api-tokens.test.ts +44 -0
  18. package/src/__tests__/audience-gate.test.ts +24 -0
  19. package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
  20. package/src/__tests__/chrome-strip.test.ts +18 -1
  21. package/src/__tests__/doctor.test.ts +10 -17
  22. package/src/__tests__/hub-command.test.ts +70 -2
  23. package/src/__tests__/hub-server.test.ts +322 -1
  24. package/src/__tests__/hub-settings.test.ts +110 -6
  25. package/src/__tests__/hub.test.ts +29 -0
  26. package/src/__tests__/install.test.ts +359 -5
  27. package/src/__tests__/migrate.test.ts +3 -1
  28. package/src/__tests__/notes-serve.test.ts +216 -0
  29. package/src/__tests__/oauth-handlers.test.ts +19 -8
  30. package/src/__tests__/operator-token.test.ts +1 -2
  31. package/src/__tests__/port-assign.test.ts +37 -17
  32. package/src/__tests__/root-serve.test.ts +139 -0
  33. package/src/__tests__/scope-explanations.test.ts +0 -2
  34. package/src/__tests__/serve-boot.test.ts +25 -36
  35. package/src/__tests__/service-spec-discovery.test.ts +30 -35
  36. package/src/__tests__/services-manifest.test.ts +372 -132
  37. package/src/__tests__/setup-wizard.test.ts +301 -22
  38. package/src/__tests__/setup.test.ts +13 -14
  39. package/src/__tests__/status.test.ts +0 -5
  40. package/src/__tests__/surface-notes-alias.test.ts +296 -0
  41. package/src/__tests__/wizard-transcription.test.ts +35 -0
  42. package/src/__tests__/wizard.test.ts +79 -0
  43. package/src/account-api.ts +6 -0
  44. package/src/admin-connections.ts +4 -2
  45. package/src/admin-lock.ts +1 -2
  46. package/src/admin-module-token.ts +13 -8
  47. package/src/admin-surfaces.ts +2 -1
  48. package/src/api-hub-upgrade.ts +2 -1
  49. package/src/api-mint-token.ts +2 -1
  50. package/src/api-modules-ops.ts +2 -1
  51. package/src/api-modules.ts +10 -8
  52. package/src/api-revoke-token.ts +2 -1
  53. package/src/api-settings-hub-origin.ts +2 -1
  54. package/src/api-settings-root-redirect.ts +97 -20
  55. package/src/api-tokens.ts +2 -1
  56. package/src/audience-gate.ts +2 -1
  57. package/src/chrome-strip.ts +16 -4
  58. package/src/commands/hub.ts +104 -0
  59. package/src/commands/install.ts +97 -8
  60. package/src/commands/migrate.ts +5 -1
  61. package/src/commands/setup.ts +9 -6
  62. package/src/commands/wizard-transcription.ts +24 -0
  63. package/src/commands/wizard.ts +34 -1
  64. package/src/help.ts +6 -6
  65. package/src/hub-server.ts +111 -54
  66. package/src/hub-settings.ts +147 -0
  67. package/src/hub.ts +64 -31
  68. package/src/module-ops-client.ts +2 -1
  69. package/src/notes-serve.ts +73 -31
  70. package/src/oauth-handlers.ts +1 -11
  71. package/src/operator-token.ts +0 -1
  72. package/src/origin-check.ts +2 -2
  73. package/src/root-serve.ts +156 -0
  74. package/src/scope-explanations.ts +2 -5
  75. package/src/service-spec.ts +129 -74
  76. package/src/services-manifest.ts +112 -52
  77. package/src/setup-wizard.ts +144 -31
  78. package/src/surface-notes-alias.ts +126 -0
  79. package/src/__tests__/admin-agent-token.test.ts +0 -173
  80. package/src/admin-agent-token.ts +0 -147
@@ -0,0 +1,296 @@
1
+ /**
2
+ * W2-12 — `/surface/notes` → `/surface/parachute` conditional alias.
3
+ *
4
+ * The condition is decided on `uis{}` MOUNT PATHS (the values
5
+ * `resolveUiMount` routes on), not map keys: fire only when no sub-unit
6
+ * resolves at/under `/surface/notes` AND one is mounted at exactly
7
+ * `/surface/parachute`. Unit tests pin the helper's condition + target
8
+ * shape; the integration tests drive `hubFetch` end-to-end to prove the
9
+ * dispatch placement (before the generic services proxy) and — the
10
+ * load-bearing half — that an EXISTING notes-ui install (a `/surface/notes`
11
+ * uis mount) passes through to its upstream untouched.
12
+ */
13
+ import { describe, expect, test } from "bun:test";
14
+ import { mkdtempSync, rmSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { hubFetch } from "../hub-server.ts";
18
+ import { clearNotesRedirectLogState } from "../notes-redirect.ts";
19
+ import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
20
+ import { isSurfaceNotesPath, maybeRedirectSurfaceNotes } from "../surface-notes-alias.ts";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Manifest fixtures
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** A surface-host row carrying the given uis map (or none). */
27
+ function surfaceRow(uis: ServiceEntry["uis"] | undefined, port = 1946): ServiceEntry {
28
+ const entry: ServiceEntry = {
29
+ name: "parachute-surface",
30
+ port,
31
+ paths: ["/surface"],
32
+ health: "/surface/healthz",
33
+ version: "0.4.0",
34
+ };
35
+ if (uis !== undefined) entry.uis = uis;
36
+ return entry;
37
+ }
38
+
39
+ /** Post-rename install: the app surface registered at /surface/parachute. */
40
+ const PARACHUTE_UIS: ServiceEntry["uis"] = {
41
+ parachute: { displayName: "Parachute", path: "/surface/parachute", audience: "public" },
42
+ };
43
+
44
+ /** Existing install: notes-ui still registered at /surface/notes. */
45
+ const NOTES_UIS: ServiceEntry["uis"] = {
46
+ notes: { displayName: "Notes", path: "/surface/notes", audience: "public" },
47
+ };
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // isSurfaceNotesPath — boundary shape
51
+ // ---------------------------------------------------------------------------
52
+
53
+ describe("isSurfaceNotesPath", () => {
54
+ test("matches the bare mount, trailing slash, and sub-paths", () => {
55
+ expect(isSurfaceNotesPath("/surface/notes")).toBe(true);
56
+ expect(isSurfaceNotesPath("/surface/notes/")).toBe(true);
57
+ expect(isSurfaceNotesPath("/surface/notes/index.html")).toBe(true);
58
+ expect(isSurfaceNotesPath("/surface/notes/a/b?ignored-not-part-of-path")).toBe(true);
59
+ });
60
+
61
+ test("does NOT match sibling prefixes or other mounts", () => {
62
+ expect(isSurfaceNotesPath("/surface/notesy")).toBe(false);
63
+ expect(isSurfaceNotesPath("/surface/notes-archive/")).toBe(false);
64
+ expect(isSurfaceNotesPath("/surface/parachute")).toBe(false);
65
+ expect(isSurfaceNotesPath("/notes/")).toBe(false);
66
+ expect(isSurfaceNotesPath("/surface")).toBe(false);
67
+ });
68
+ });
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // maybeRedirectSurfaceNotes — the condition + target shape
72
+ // ---------------------------------------------------------------------------
73
+
74
+ describe("maybeRedirectSurfaceNotes", () => {
75
+ test("fires when /surface/parachute is mounted and /surface/notes is not", () => {
76
+ const services = [surfaceRow(PARACHUTE_UIS)];
77
+ expect(maybeRedirectSurfaceNotes("/surface/notes", "", services)).toBe("/surface/parachute");
78
+ expect(maybeRedirectSurfaceNotes("/surface/notes/", "", services)).toBe("/surface/parachute/");
79
+ expect(maybeRedirectSurfaceNotes("/surface/notes/a/b.html", "", services)).toBe(
80
+ "/surface/parachute/a/b.html",
81
+ );
82
+ });
83
+
84
+ test("preserves the query string on the target", () => {
85
+ const services = [surfaceRow(PARACHUTE_UIS)];
86
+ expect(maybeRedirectSurfaceNotes("/surface/notes/x", "?q=1&n=2", services)).toBe(
87
+ "/surface/parachute/x?q=1&n=2",
88
+ );
89
+ });
90
+
91
+ test("INERT when a /surface/notes mount exists (existing notes-ui install)", () => {
92
+ expect(maybeRedirectSurfaceNotes("/surface/notes/x", "", [surfaceRow(NOTES_UIS)])).toBe(
93
+ undefined,
94
+ );
95
+ // ... even when a parachute mount ALSO exists (transitional both-present
96
+ // install) — a live legacy mount is never preempted.
97
+ const both = [surfaceRow({ ...NOTES_UIS, ...PARACHUTE_UIS })];
98
+ expect(maybeRedirectSurfaceNotes("/surface/notes/x", "", both)).toBe(undefined);
99
+ });
100
+
101
+ test("INERT when no uis{} exist at all (pre-uis surface-host rows — today's live shape)", () => {
102
+ expect(maybeRedirectSurfaceNotes("/surface/notes/x", "", [surfaceRow(undefined)])).toBe(
103
+ undefined,
104
+ );
105
+ expect(maybeRedirectSurfaceNotes("/surface/notes/x", "", [])).toBe(undefined);
106
+ });
107
+
108
+ test("INERT when the target mount is absent — never redirects into a 404", () => {
109
+ // A `parachute` KEY whose path was customized elsewhere does not count:
110
+ // the redirect target is the literal /surface/parachute mount, so its
111
+ // existence (by path) is part of the condition.
112
+ const customized: ServiceEntry["uis"] = {
113
+ parachute: { displayName: "Parachute", path: "/surface/app", audience: "public" },
114
+ };
115
+ expect(maybeRedirectSurfaceNotes("/surface/notes/x", "", [surfaceRow(customized)])).toBe(
116
+ undefined,
117
+ );
118
+ });
119
+
120
+ test("fires in the in-place-upgrade caveat: uis KEY still `notes`, PATH flipped to /surface/parachute", () => {
121
+ // Re-adding the renamed package over the old instance with
122
+ // `instance_name=notes` and no `mount_path` keeps the map key `notes`
123
+ // while the mount flips — exactly the orphaned-bookmark scenario this
124
+ // alias exists for. The path-based condition (not key-based) covers it.
125
+ const upgraded: ServiceEntry["uis"] = {
126
+ notes: { displayName: "Parachute", path: "/surface/parachute", audience: "public" },
127
+ };
128
+ expect(maybeRedirectSurfaceNotes("/surface/notes/x", "?a=1", [surfaceRow(upgraded)])).toBe(
129
+ "/surface/parachute/x?a=1",
130
+ );
131
+ });
132
+
133
+ test("a sub-unit mounted UNDER /surface/notes counts as the legacy identity resolving", () => {
134
+ // A deeper mount like /surface/notes/foo still serves real content under
135
+ // the legacy prefix; redirecting across it would hijack a live route.
136
+ const deep: ServiceEntry["uis"] = {
137
+ ...PARACHUTE_UIS,
138
+ foo: { displayName: "Foo", path: "/surface/notes/foo", audience: "public" },
139
+ };
140
+ expect(maybeRedirectSurfaceNotes("/surface/notes/foo/x", "", [surfaceRow(deep)])).toBe(
141
+ undefined,
142
+ );
143
+ });
144
+
145
+ test("non-matching pathnames return undefined regardless of manifest state", () => {
146
+ const services = [surfaceRow(PARACHUTE_UIS)];
147
+ expect(maybeRedirectSurfaceNotes("/surface/notesy", "", services)).toBe(undefined);
148
+ expect(maybeRedirectSurfaceNotes("/surface/parachute/x", "", services)).toBe(undefined);
149
+ expect(maybeRedirectSurfaceNotes("/notes/x", "", services)).toBe(undefined);
150
+ });
151
+
152
+ test("normalizes trailing slashes on declared uis paths", () => {
153
+ const slashed: ServiceEntry["uis"] = {
154
+ parachute: { displayName: "Parachute", path: "/surface/parachute/", audience: "public" },
155
+ };
156
+ expect(maybeRedirectSurfaceNotes("/surface/notes/x", "", [surfaceRow(slashed)])).toBe(
157
+ "/surface/parachute/x",
158
+ );
159
+ });
160
+ });
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // hubFetch integration — dispatch placement + passthrough
164
+ // ---------------------------------------------------------------------------
165
+
166
+ interface Harness {
167
+ dir: string;
168
+ manifestPath: string;
169
+ cleanup: () => void;
170
+ }
171
+
172
+ function makeHarness(): Harness {
173
+ const dir = mkdtempSync(join(tmpdir(), "pcli-surface-notes-alias-"));
174
+ return {
175
+ dir,
176
+ manifestPath: join(dir, "services.json"),
177
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
178
+ };
179
+ }
180
+
181
+ function req(path: string): Request {
182
+ return new Request(`http://127.0.0.1${path}`);
183
+ }
184
+
185
+ // Loopback peer so the default publicExposure cloak never interferes —
186
+ // same shape audience-gate.test.ts uses.
187
+ const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
188
+
189
+ function startEchoUpstream(): { port: number; stop: () => void } {
190
+ const server = Bun.serve({
191
+ port: 0,
192
+ hostname: "127.0.0.1",
193
+ fetch: (r) =>
194
+ new Response(JSON.stringify({ upstream: "notes-ui", path: new URL(r.url).pathname }), {
195
+ status: 200,
196
+ headers: { "content-type": "application/json" },
197
+ }),
198
+ });
199
+ return { port: server.port as number, stop: () => server.stop(true) };
200
+ }
201
+
202
+ describe("hubFetch × surface-notes alias (W2-12)", () => {
203
+ test("301: /surface/notes/* → /surface/parachute/* when only the parachute mount exists (tail + query preserved)", async () => {
204
+ clearNotesRedirectLogState();
205
+ const h = makeHarness();
206
+ try {
207
+ writeManifest({ services: [surfaceRow(PARACHUTE_UIS)] }, h.manifestPath);
208
+ const f = hubFetch(h.dir, { manifestPath: h.manifestPath });
209
+ const res = await f(req("/surface/notes/some/path?q=1&n=2"), fakeServer("127.0.0.1"));
210
+ expect(res.status).toBe(301);
211
+ expect(res.headers.get("location")).toBe("/surface/parachute/some/path?q=1&n=2");
212
+ } finally {
213
+ h.cleanup();
214
+ }
215
+ });
216
+
217
+ test("301: bare /surface/notes → /surface/parachute", async () => {
218
+ clearNotesRedirectLogState();
219
+ const h = makeHarness();
220
+ try {
221
+ writeManifest({ services: [surfaceRow(PARACHUTE_UIS)] }, h.manifestPath);
222
+ const f = hubFetch(h.dir, { manifestPath: h.manifestPath });
223
+ const res = await f(req("/surface/notes"), fakeServer("127.0.0.1"));
224
+ expect(res.status).toBe(301);
225
+ expect(res.headers.get("location")).toBe("/surface/parachute");
226
+ } finally {
227
+ h.cleanup();
228
+ }
229
+ });
230
+
231
+ test("200 passthrough: an existing notes-ui install (notes uis mount) proxies to its upstream untouched", async () => {
232
+ clearNotesRedirectLogState();
233
+ const upstream = startEchoUpstream();
234
+ const h = makeHarness();
235
+ try {
236
+ writeManifest({ services: [surfaceRow(NOTES_UIS, upstream.port)] }, h.manifestPath);
237
+ const f = hubFetch(h.dir, { manifestPath: h.manifestPath });
238
+ const res = await f(req("/surface/notes/index.html"), fakeServer("127.0.0.1"));
239
+ expect(res.status).toBe(200);
240
+ const body = (await res.json()) as { upstream: string; path: string };
241
+ // The blind proxy forwarded the ORIGINAL path — no rewrite rode along.
242
+ expect(body.upstream).toBe("notes-ui");
243
+ expect(body.path).toBe("/surface/notes/index.html");
244
+ } finally {
245
+ upstream.stop();
246
+ h.cleanup();
247
+ }
248
+ });
249
+
250
+ test("404: /surface/notesy (sibling prefix) is untouched by the alias", async () => {
251
+ clearNotesRedirectLogState();
252
+ const h = makeHarness();
253
+ try {
254
+ // parachute-mount-only manifest, but NO row claims /surface/notesy —
255
+ // the boundary check keeps the alias out of the way and the dispatch
256
+ // falls through to the branded 404.
257
+ writeManifest(
258
+ {
259
+ services: [
260
+ {
261
+ name: "parachute-app",
262
+ port: 1944,
263
+ paths: ["/app"],
264
+ health: "/app",
265
+ version: "0.5.0",
266
+ uis: PARACHUTE_UIS,
267
+ },
268
+ ],
269
+ },
270
+ h.manifestPath,
271
+ );
272
+ const f = hubFetch(h.dir, { manifestPath: h.manifestPath });
273
+ const res = await f(req("/surface/notesy"), fakeServer("127.0.0.1"));
274
+ expect(res.status).toBe(404);
275
+ } finally {
276
+ h.cleanup();
277
+ }
278
+ });
279
+
280
+ test("404 (not 301): no uis at all — today's live shape stays inert end-to-end", async () => {
281
+ clearNotesRedirectLogState();
282
+ const h = makeHarness();
283
+ try {
284
+ // A pre-uis surface row would normally proxy /surface/* blind; with no
285
+ // live upstream the proxy path yields a 502/404-class response — the
286
+ // assertion here is only that NO 301 fires. Use an empty manifest so
287
+ // the outcome is a deterministic 404.
288
+ writeManifest({ services: [] }, h.manifestPath);
289
+ const f = hubFetch(h.dir, { manifestPath: h.manifestPath });
290
+ const res = await f(req("/surface/notes/"), fakeServer("127.0.0.1"));
291
+ expect(res.status).toBe(404);
292
+ } finally {
293
+ h.cleanup();
294
+ }
295
+ });
296
+ });
@@ -82,6 +82,41 @@ describe("walkTranscriptionStep — mode resolution (the CLI question)", () => {
82
82
  }
83
83
  });
84
84
 
85
+ test("non-interactive stdin (no flag) DEFAULTS to none — never hangs on the prompt", async () => {
86
+ // Headless-hardening: with no `--transcribe-mode` flag AND a closed / non-
87
+ // interactive stdin, an interactive `prompt("Pick [1]:")` would busy-hang
88
+ // Bun's readline question() forever (the exact e2e wedge). Transcription is
89
+ // optional and documented as never-blocking, so resolveChoice DEFAULTS to
90
+ // "none" (with an honest log line) rather than throwing. We drive the real
91
+ // defaultPrompt (no `prompt` seam) and force isTTY=false for determinism;
92
+ // if the guard regressed, this test would HANG instead of passing.
93
+ const h = makeHarness();
94
+ const origIsTTY = process.stdin.isTTY;
95
+ Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
96
+ try {
97
+ const r = recordingRunner();
98
+ const logs: string[] = [];
99
+ const code = await walkTranscriptionStep({
100
+ configDir: h.dir,
101
+ log: (l) => logs.push(l),
102
+ // no transcribeMode, no prompt seam → real defaultPrompt would be hit
103
+ runCommand: r.run,
104
+ platform: "linux",
105
+ });
106
+ expect(code).toBe(0);
107
+ expect(r.cmds).toEqual([]); // nothing installed
108
+ expect(readCfg(h.dir)).toBeUndefined(); // no provider recorded
109
+ expect(logs.join("\n")).toContain("not interactive");
110
+ expect(logs.join("\n")).toContain("Transcription off");
111
+ } finally {
112
+ Object.defineProperty(process.stdin, "isTTY", {
113
+ value: origIsTTY,
114
+ configurable: true,
115
+ });
116
+ h.cleanup();
117
+ }
118
+ });
119
+
85
120
  test("interactive prompt: '3' then 'g' chooses groq cloud", async () => {
86
121
  const h = makeHarness();
87
122
  try {
@@ -655,4 +655,83 @@ describe("runCliWizard", () => {
655
655
  "/admin/setup/expose",
656
656
  ]);
657
657
  });
658
+
659
+ test("non-interactive stdin fails fast (no hang) when a required answer is missing", async () => {
660
+ // Headless-hardening: with stdin closed (cloud-init, `ssh host 'parachute
661
+ // setup-wizard …'`, run.sh-exec'd stages.sh) an unanswered prompt would
662
+ // busy-hang Bun's readline question() forever. defaultPrompt now throws a
663
+ // clear, flag-naming error instead. We drive the real defaultPrompt (no
664
+ // `prompt` seam) with the account username unsupplied, forcing the account
665
+ // step to prompt — and force isTTY=false so the assertion is deterministic
666
+ // regardless of how the test runner's stdin is wired.
667
+ const { fetchImpl } = makeFakeHub();
668
+ const origIsTTY = process.stdin.isTTY;
669
+ Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
670
+ try {
671
+ await expect(
672
+ runCliWizard({
673
+ hubUrl: "http://127.0.0.1:1939",
674
+ log: () => {},
675
+ fetchImpl,
676
+ sleep: async () => {},
677
+ // no accountUsername → account step calls the real defaultPrompt.
678
+ accountPassword: "longpassword",
679
+ vaultMode: "skip",
680
+ exposeMode: "localhost",
681
+ }),
682
+ ).rejects.toThrow(/stdin is not interactive/);
683
+ } finally {
684
+ Object.defineProperty(process.stdin, "isTTY", {
685
+ value: origIsTTY,
686
+ configurable: true,
687
+ });
688
+ }
689
+ });
690
+
691
+ test("headless runCliWizard with NO --transcribe-mode completes (transcription defaults to none, not a throw)", async () => {
692
+ // Regression guard for the review must-fix: `runCliWizard` used to forward
693
+ // its (throwing) `defaultPrompt` into the transcription step, so a headless
694
+ // run with account/vault/expose all supplied via flags but NO
695
+ // `--transcribe-mode` would THROW at the transcription step ("cannot prompt
696
+ // for: Pick [1]:") instead of defaulting to none. The wizard now forwards a
697
+ // prompt to the transcription step ONLY when a real seam is injected, so
698
+ // headless hits `resolveChoice`'s undefined-prompt non-TTY guard → none.
699
+ const { state, fetchImpl } = makeFakeHub();
700
+ const logs: string[] = [];
701
+ let ran = false;
702
+ const origIsTTY = process.stdin.isTTY;
703
+ Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
704
+ try {
705
+ const code = await runCliWizard({
706
+ hubUrl: "http://127.0.0.1:1939",
707
+ log: (l) => logs.push(l),
708
+ fetchImpl,
709
+ sleep: async () => {},
710
+ accountUsername: "admin",
711
+ accountPassword: "longpassword",
712
+ vaultMode: "skip",
713
+ exposeMode: "localhost",
714
+ configDir: "/tmp/pcli-wizard-headless-none", // triggers the step; none writes nothing
715
+ // NO transcribeMode, NO prompt seam → headless must default to none.
716
+ transcribeRunCommand: async () => {
717
+ ran = true;
718
+ return 0;
719
+ },
720
+ });
721
+ expect(code).toBe(0); // completed, did NOT throw
722
+ expect(ran).toBe(false); // none → no scribe install
723
+ expect(logs.join("\n")).toContain("Transcription off");
724
+ // Full flow still walked account → vault → expose.
725
+ expect(state.posted.map((p) => p.path)).toEqual([
726
+ "/admin/setup/account",
727
+ "/admin/setup/vault",
728
+ "/admin/setup/expose",
729
+ ]);
730
+ } finally {
731
+ Object.defineProperty(process.stdin, "isTTY", {
732
+ value: origIsTTY,
733
+ configurable: true,
734
+ });
735
+ }
736
+ });
658
737
  });
@@ -33,6 +33,12 @@
33
33
  * ownership gate the cloud twin runs per-vault is trivially satisfied here.
34
34
  */
35
35
  import type { Database } from "bun:sqlite";
36
+ // NOTE: this is a VALUE import — hub can't run without
37
+ // `@openparachute/door-contract` resolving at runtime, so it's a real
38
+ // `^0.6.0` runtime `dependency` (package.json, mirroring `@openparachute/depcheck`)
39
+ // and NOT a `workspace:*` devDependency. The published hub tarball doesn't ship
40
+ // `packages/`, so the npm-installed hub resolves door-contract from the registry.
41
+ // See RELEASING.md → "Releasing door-contract".
36
42
  import {
37
43
  type AccountBootstrap,
38
44
  type ParachuteAccountDescriptor,
@@ -1082,7 +1082,8 @@ async function renewCredentialConnection(
1082
1082
  }
1083
1083
 
1084
1084
  const auth = req.headers.get("authorization");
1085
- if (!auth || !auth.startsWith("Bearer ")) {
1085
+ // Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
1086
+ if (!auth || !/^Bearer\s+/i.test(auth)) {
1086
1087
  return jsonError(
1087
1088
  401,
1088
1089
  "unauthenticated",
@@ -1257,7 +1258,8 @@ async function claimCredentialConnection(
1257
1258
  }
1258
1259
 
1259
1260
  const auth = req.headers.get("authorization");
1260
- if (!auth || !auth.startsWith("Bearer ")) {
1261
+ // Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
1262
+ if (!auth || !/^Bearer\s+/i.test(auth)) {
1261
1263
  return jsonError(
1262
1264
  401,
1263
1265
  "unauthenticated",
package/src/admin-lock.ts CHANGED
@@ -24,11 +24,10 @@
24
24
  * The admin SPA + every module config UI get their working Bearer from one of
25
25
  * the cookie-gated mint endpoints:
26
26
  * - `GET /admin/host-admin-token` (the SPA's own Bearer)
27
- * - `GET /admin/agent-token` (agent chat + config UIs)
28
27
  * - `GET /admin/vault-admin-token/<name>`(per-vault admin SPA)
29
28
  * - `GET /admin/module-token/<short>` (generic module config UI Bearer)
30
29
  *
31
- * All four share the exact `parseSessionCookie → findSession → [isFirstAdmin]
30
+ * All three share the exact `parseSessionCookie → findSession → [isFirstAdmin]
32
31
  * → signAccessToken` shape. Inserting {@link requireUnlocked} into each makes
33
32
  * the lock cascade to EVERY admin surface with no per-module changes: when
34
33
  * locked, the mint returns 423 and the relevant UI shows the lock screen / its
@@ -8,15 +8,14 @@
8
8
  * hub frames/links those surfaces consistently (the Modules page "Configure"
9
9
  * action). Each module-owned config UI, served behind the hub proxy to a
10
10
  * logged-in portal operator, needs an admin-scoped hub Bearer to call its own
11
- * `<short>:admin`-gated endpoints the same shape the agent config UI gets
12
- * from `/admin/agent-token` and the vault admin SPA gets from
13
- * `/admin/vault-admin-token/<name>`. This is the GENERIC mint that covers every
14
- * other self-registered single-audience module, so the hub doesn't grow a
11
+ * `<short>:admin`-gated endpoints. This generic mint covers every
12
+ * self-registered single-audience module, while the vault admin SPA uses
13
+ * `/admin/vault-admin-token/<name>`, so the hub doesn't grow a
15
14
  * bespoke per-module mint endpoint as each module ships a config UI.
16
15
  *
17
16
  * Scope + audience: `<short>:admin`, audience = `<short>` (the bare service
18
17
  * prefix). Modules validate the JWT's `aud` against their literal short name
19
- * (`scribe`, `surface`, `agent`) — the same shape `inferAudience`
18
+ * (`scribe`, `surface`, or a third-party short) — the same shape `inferAudience`
20
19
  * stamps for the public OAuth flow, so a hub-minted and an OAuth-minted admin
21
20
  * token are indistinguishable to the module. This mirrors the per-request
22
21
  * `<short>:admin` proxy token `api-modules-config.ts` used to mint; the
@@ -32,7 +31,7 @@
32
31
  *
33
32
  * Gate: the session must belong to the first admin (the single hub admin under
34
33
  * the Phase 1 multi-user model — `users.ts:isFirstAdmin`), exactly like
35
- * host-admin-token / vault-admin-token / agent-token. A friend account holds
34
+ * host-admin-token and vault-admin-token. A friend account holds
36
35
  * a valid session but must not mint a module admin Bearer.
37
36
  *
38
37
  * Tokens are short-lived (10 min — matches the sibling admin-token mints); the
@@ -45,12 +44,12 @@ import {
45
44
  type ModuleManifest,
46
45
  readModuleManifest as defaultReadModuleManifest,
47
46
  } from "./module-manifest.ts";
48
- import { findServiceByShort, isKnownModuleShort } from "./service-spec.ts";
47
+ import { RETIRED_MODULES, findServiceByShort, isKnownModuleShort } from "./service-spec.ts";
49
48
  import type { ServiceEntry } from "./services-manifest.ts";
50
49
  import { findSession, parseSessionCookie } from "./sessions.ts";
51
50
  import { isFirstAdmin } from "./users.ts";
52
51
 
53
- /** Short TTL — matches host/vault/agent admin-token. UI re-fetches on near-expiry. */
52
+ /** Short TTL — matches sibling admin-token mints. UI re-fetches on near-expiry. */
54
53
  export const MODULE_TOKEN_TTL_SECONDS = 10 * 60;
55
54
  const MODULE_TOKEN_CLIENT_ID = "parachute-hub-spa";
56
55
 
@@ -115,6 +114,12 @@ export async function handleModuleToken(
115
114
  if (!MODULE_SHORT_RE.test(short)) {
116
115
  return jsonError(400, "invalid_request", `module short "${short}" is not a valid identifier`);
117
116
  }
117
+ // Retirement wins over the generic self-registration trust path. Otherwise a
118
+ // stale row plus readable module.json could keep minting after Hub support is
119
+ // deliberately removed.
120
+ if (RETIRED_MODULES[short]) {
121
+ return jsonError(404, "not_found", `no module "${short}" known to this hub`);
122
+ }
118
123
  // Vault is per-instance — its admin scope needs a vault name. Route the caller
119
124
  // to the dedicated per-vault endpoint rather than minting a useless bare
120
125
  // `vault:admin` (no vault validates that audience).
@@ -61,7 +61,8 @@ function jsonError(status: number, error: string, description: string): Response
61
61
  /** Validate the operator bearer + require the surfaces scope. Mirrors api-modules-ops. */
62
62
  async function authorize(req: Request, deps: AdminSurfacesDeps): Promise<Response | undefined> {
63
63
  const auth = req.headers.get("authorization");
64
- if (!auth || !auth.startsWith("Bearer ")) {
64
+ // Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
65
+ if (!auth || !/^Bearer\s+/i.test(auth)) {
65
66
  return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
66
67
  }
67
68
  const bearer = auth.slice("Bearer ".length).trim();
@@ -158,7 +158,8 @@ interface ParsedBody {
158
158
 
159
159
  async function authorize(req: Request, deps: ApiHubUpgradeDeps): Promise<Response | undefined> {
160
160
  const auth = req.headers.get("authorization");
161
- if (!auth || !auth.startsWith("Bearer ")) {
161
+ // Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
162
+ if (!auth || !/^Bearer\s+/i.test(auth)) {
162
163
  return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
163
164
  }
164
165
  const bearer = auth.slice("Bearer ".length).trim();
@@ -132,7 +132,8 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
132
132
 
133
133
  // 1. Bearer presence + parsing.
134
134
  const auth = req.headers.get("authorization");
135
- if (!auth || !auth.startsWith("Bearer ")) {
135
+ // Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
136
+ if (!auth || !/^Bearer\s+/i.test(auth)) {
136
137
  return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
137
138
  }
138
139
  const bearer = auth.slice("Bearer ".length).trim();
@@ -308,7 +308,8 @@ export function parseModulesPath(pathname: string): PathMatch | undefined {
308
308
 
309
309
  async function authorize(req: Request, deps: ApiModulesOpsDeps): Promise<Response | undefined> {
310
310
  const auth = req.headers.get("authorization");
311
- if (!auth || !auth.startsWith("Bearer ")) {
311
+ // Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
312
+ if (!auth || !/^Bearer\s+/i.test(auth)) {
312
313
  return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
313
314
  }
314
315
  const bearer = auth.slice("Bearer ".length).trim();
@@ -82,7 +82,7 @@ import type { ModuleStartError, ModuleState, Supervisor } from "./supervisor.ts"
82
82
 
83
83
  /**
84
84
  * Resolve a known module to the display + install bootstrap data the admin SPA
85
- * renders. Reads from FIRST_PARTY_FALLBACKS (notes) first,
85
+ * renders. Reads from FIRST_PARTY_FALLBACKS (notes / app) first,
86
86
  * KNOWN_MODULES (vault / scribe / agent / surface) second.
87
87
  *
88
88
  * Returns `undefined` if the short is in neither table — a genuinely
@@ -351,15 +351,15 @@ interface ModuleWireShape {
351
351
  * `.parachute/module.json` `configUiUrl`, joined against its mount path the
352
352
  * same way `management_url` resolves `managementUrl`/`uiUrl`. Drives the
353
353
  * Modules page's consistent **Configure** action — clicking lands the
354
- * operator on the module's own config UI (agent `/agent/admin`, scribe
355
- * `/scribe/admin`, …), which mints its admin Bearer from the hub's
356
- * cookie-gated `/admin/module-token/<short>` (or `/admin/agent-token`).
354
+ * operator on the module's own config UI (for example `/scribe/admin`),
355
+ * which mints its admin Bearer from the hub's cookie-gated
356
+ * `/admin/module-token/<short>`.
357
357
  *
358
358
  * Null when the module hasn't declared `configUiUrl` — the SPA omits the
359
359
  * Configure action for that module rather than rendering a dead button.
360
360
  * Distinct from `management_url`: a module may declare one, both, or
361
- * neither. Agent declares `configUiUrl: "/agent/admin"` + `uiUrl`;
362
- * vault declares `managementUrl` (its admin SPA is the config surface).
361
+ * neither. Vault declares `managementUrl` (its admin SPA is the config
362
+ * surface).
363
363
  */
364
364
  config_ui_url: string | null;
365
365
  }
@@ -462,7 +462,8 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
462
462
 
463
463
  // Bearer presence + parsing.
464
464
  const auth = req.headers.get("authorization");
465
- if (!auth || !auth.startsWith("Bearer ")) {
465
+ // Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
466
+ if (!auth || !/^Bearer\s+/i.test(auth)) {
466
467
  return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
467
468
  }
468
469
  const bearer = auth.slice("Bearer ".length).trim();
@@ -780,7 +781,8 @@ export async function handleApiModulesChannel(
780
781
 
781
782
  // Bearer presence + parsing.
782
783
  const auth = req.headers.get("authorization");
783
- if (!auth || !auth.startsWith("Bearer ")) {
784
+ // Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
785
+ if (!auth || !/^Bearer\s+/i.test(auth)) {
784
786
  return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
785
787
  }
786
788
  const bearer = auth.slice("Bearer ".length).trim();
@@ -100,7 +100,8 @@ export async function handleApiRevokeToken(
100
100
 
101
101
  // 1. Bearer presence + parsing.
102
102
  const auth = req.headers.get("authorization");
103
- if (!auth || !auth.startsWith("Bearer ")) {
103
+ // Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
104
+ if (!auth || !/^Bearer\s+/i.test(auth)) {
104
105
  return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
105
106
  }
106
107
  const bearer = auth.slice("Bearer ".length).trim();
@@ -185,7 +185,8 @@ export async function handleApiSettingsHubOrigin(
185
185
  // Bearer presence + parsing — identical shape to api-modules
186
186
  // for consistency across hub-internal admin endpoints.
187
187
  const auth = req.headers.get("authorization");
188
- if (!auth || !auth.startsWith("Bearer ")) {
188
+ // Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
189
+ if (!auth || !/^Bearer\s+/i.test(auth)) {
189
190
  return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
190
191
  }
191
192
  const bearer = auth.slice("Bearer ".length).trim();