@openparachute/hub 0.6.5-rc.5 → 0.6.5-rc.6

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.6.5-rc.5",
3
+ "version": "0.6.5-rc.6",
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": {
@@ -9,7 +9,9 @@
9
9
  * OK envelope.
10
10
  * 3. POST /admin/setup/vault (application/json) → 200 OK envelope
11
11
  * with `op_id`.
12
- * 4. GET /api/modules/operations/<id> until terminal status.
12
+ * 4. GET /admin/setup?op=<id> until the `operation` envelope field
13
+ * reaches a terminal status (hub#616 — session-authed poll surface,
14
+ * NOT the Bearer-gated /api/modules/operations/:id the SPA uses).
13
15
  * 5. POST /admin/setup/expose (application/json) → 200 OK.
14
16
  *
15
17
  * The stub fetch in this file is a mini-router that mimics the
@@ -32,6 +34,15 @@ interface FakeHubState {
32
34
  importParams?: { remoteUrl: string; pat?: string; mode: string };
33
35
  exposeMode?: string;
34
36
  posted: Array<{ path: string; body: unknown }>;
37
+ /** hub#616: path+query of every op-poll GET, to assert the wizard polls the session surface. */
38
+ polled: string[];
39
+ /**
40
+ * hub#616: number of poll ticks the vault op reports `"running"` before
41
+ * flipping to `"succeeded"`. 0 (default) = succeeds immediately on the first
42
+ * poll. >0 exercises the multi-tick poll loop (the import-mode long-running
43
+ * provisioning path).
44
+ */
45
+ vaultProvisionTicks?: number;
35
46
  /** hub#576: when set, the fake GET /admin/setup reports requireBootstrapToken=true. */
36
47
  requireBootstrapToken?: boolean;
37
48
  /** hub#576: when set, the fake GET also returns it (loopback-probe behavior). */
@@ -52,6 +63,7 @@ function makeFakeHub(initialState?: Partial<FakeHubState>): {
52
63
  hasVault: false,
53
64
  hasExposeMode: false,
54
65
  posted: [],
66
+ polled: [],
55
67
  ...initialState,
56
68
  };
57
69
  // Synthesize a stable CSRF token + session token for the stub. The
@@ -69,6 +81,9 @@ function makeFakeHub(initialState?: Partial<FakeHubState>): {
69
81
  error?: string;
70
82
  }
71
83
  >();
84
+ // hub#616: per-op countdown of remaining `"running"` poll ticks before the
85
+ // op flips to `"succeeded"` (see FakeHubState.vaultProvisionTicks).
86
+ const opTicksRemaining = new Map<string, number>();
72
87
 
73
88
  const fetchImpl = async (
74
89
  input: string | URL | Request,
@@ -81,12 +96,28 @@ function makeFakeHub(initialState?: Partial<FakeHubState>): {
81
96
  const body = init?.body;
82
97
  const bodyJson: unknown = body ? JSON.parse(String(body)) : null;
83
98
 
84
- // GET /admin/setup
85
- if (path === "/admin/setup" && method === "GET") {
99
+ // GET /admin/setup (incl. the `?op=<id>` poll surface — hub#616)
100
+ if (url.pathname === "/admin/setup" && method === "GET") {
86
101
  let step: "welcome" | "vault" | "expose" | "done" = "welcome";
87
102
  if (state.hasAdmin && state.hasVault && state.hasExposeMode) step = "done";
88
103
  else if (state.hasAdmin && state.hasVault) step = "expose";
89
104
  else if (state.hasAdmin) step = "vault";
105
+ // hub#616: the CLI wizard polls vault provisioning via this session-authed
106
+ // GET with `?op=<id>`; the op snapshot rides in the `operation` field.
107
+ const opId = url.searchParams.get("op");
108
+ if (opId) state.polled.push(path);
109
+ const op = opId ? ops.get(opId) : undefined;
110
+ // hub#616: drive a multi-tick op (running → succeeded) so the poll loop
111
+ // is exercised across more than one fetch when configured.
112
+ if (op && op.status === "running") {
113
+ const remaining = opTicksRemaining.get(op.id) ?? 0;
114
+ if (remaining <= 0) {
115
+ op.status = "succeeded";
116
+ state.hasVault = true;
117
+ } else {
118
+ opTicksRemaining.set(op.id, remaining - 1);
119
+ }
120
+ }
90
121
  const respBody = JSON.stringify({
91
122
  step,
92
123
  hasAdmin: state.hasAdmin,
@@ -96,6 +127,7 @@ function makeFakeHub(initialState?: Partial<FakeHubState>): {
96
127
  csrfToken: csrf,
97
128
  // hub#576: a loopback probe carries the actual token value.
98
129
  ...(state.bootstrapToken ? { bootstrapToken: state.bootstrapToken } : {}),
130
+ ...(op ? { operation: op } : {}),
99
131
  });
100
132
  return new Response(respBody, {
101
133
  status: 200,
@@ -155,30 +187,36 @@ function makeFakeHub(initialState?: Partial<FakeHubState>): {
155
187
  ...(b.pat ? { pat: b.pat } : {}),
156
188
  };
157
189
  }
158
- // Create an op + drive it to succeeded immediately for the test.
190
+ // Create an op. By default it succeeds immediately on the first poll;
191
+ // when `vaultProvisionTicks` is set it reports `"running"` for that many
192
+ // poll ticks first (hub#616 — exercises the multi-tick poll loop).
159
193
  const opId = `op-test-${++opCount}`;
160
- ops.set(opId, { id: opId, status: "succeeded", log: ["bun add -g", "spawned"] });
161
- state.hasVault = true;
194
+ const ticks = state.vaultProvisionTicks ?? 0;
195
+ if (ticks > 0) {
196
+ ops.set(opId, { id: opId, status: "running", log: ["bun add -g"] });
197
+ opTicksRemaining.set(opId, ticks);
198
+ } else {
199
+ ops.set(opId, { id: opId, status: "succeeded", log: ["bun add -g", "spawned"] });
200
+ state.hasVault = true;
201
+ }
162
202
  return new Response(JSON.stringify({ op_id: opId, step: "vault", mode: b.mode }), {
163
203
  status: 200,
164
204
  headers: { "content-type": "application/json; charset=utf-8" },
165
205
  });
166
206
  }
167
207
 
168
- // GET /api/modules/operations/<id>
169
- if (path.startsWith("/api/modules/operations/") && method === "GET") {
170
- const id = path.slice("/api/modules/operations/".length);
171
- const op = ops.get(id);
172
- if (!op) {
173
- return new Response(JSON.stringify({ error: "not found" }), {
174
- status: 404,
175
- headers: { "content-type": "application/json; charset=utf-8" },
176
- });
177
- }
178
- return new Response(JSON.stringify(op), {
179
- status: 200,
180
- headers: { "content-type": "application/json; charset=utf-8" },
181
- });
208
+ // GET /api/modules/operations/<id> — the Bearer-gated SPA/install-CLI poll
209
+ // surface. hub#616: the CLI wizard must NOT use it (it holds only a session
210
+ // cookie, not a host-admin Bearer). Mirror the real 401 so any regression
211
+ // back to this path fails the wizard poll loudly instead of silently.
212
+ if (url.pathname.startsWith("/api/modules/operations/") && method === "GET") {
213
+ return new Response(
214
+ JSON.stringify({
215
+ error: "unauthenticated",
216
+ error_description: "Authorization: Bearer required",
217
+ }),
218
+ { status: 401, headers: { "content-type": "application/json; charset=utf-8" } },
219
+ );
182
220
  }
183
221
 
184
222
  // POST /admin/setup/expose
@@ -287,6 +325,37 @@ describe("runCliWizard", () => {
287
325
  expect(vaultBody.mode).toBe("create");
288
326
  expect(vaultBody.vault_name).toBe("default");
289
327
  expect(state.exposeMode).toBe("localhost");
328
+ // hub#616: the vault-provisioning op is polled over the session-authed
329
+ // wizard surface, NOT the Bearer-gated /api/modules/operations/:id (which
330
+ // the fake 401s, mirroring the real gate). At least one poll must land on
331
+ // /admin/setup?op=, and every poll must use that path.
332
+ expect(state.polled.length).toBeGreaterThan(0);
333
+ for (const p of state.polled) expect(p.startsWith("/admin/setup?op=")).toBe(true);
334
+ });
335
+
336
+ test("multi-tick vault op (running → succeeded) polls the session surface across ticks — hub#616", async () => {
337
+ // Models the import-mode long-running provisioning path: the op reports
338
+ // `running` for two poll ticks before flipping to `succeeded`.
339
+ const { state, fetchImpl } = makeFakeHub({ vaultProvisionTicks: 2 });
340
+ const logs: string[] = [];
341
+ const code = await runCliWizard({
342
+ hubUrl: "http://127.0.0.1:1939",
343
+ log: (l) => logs.push(l),
344
+ fetchImpl,
345
+ sleep: async () => {},
346
+ accountUsername: "admin",
347
+ accountPassword: "longpassword",
348
+ vaultMode: "create",
349
+ vaultName: "default",
350
+ exposeMode: "localhost",
351
+ });
352
+ expect(code).toBe(0);
353
+ // The loop ran more than once (2 running ticks + the terminal succeeded
354
+ // poll), and every tick used the session-authed surface — never the
355
+ // Bearer-gated /api/modules/operations/:id.
356
+ expect(state.polled.length).toBeGreaterThanOrEqual(3);
357
+ for (const p of state.polled) expect(p.startsWith("/admin/setup?op=")).toBe(true);
358
+ expect(state.exposeMode).toBe("localhost");
290
359
  });
291
360
 
292
361
  test("loopback-probe bootstrap token is sent transparently (no prompt) — hub#576", async () => {
@@ -347,9 +347,15 @@ async function pollOperation(
347
347
  const start = Date.now();
348
348
  let lastLogIndex = 0;
349
349
  for (;;) {
350
+ // hub#616: poll over the session-authed wizard surface (`/admin/setup?op=`),
351
+ // mirroring the browser wizard's re-GET — NOT the Bearer-gated
352
+ // `/api/modules/operations/:id` the SPA + install CLI use. Mid-setup the
353
+ // wizard holds only a session cookie; the op endpoint demands a host-admin
354
+ // Bearer it doesn't have, so a direct poll 401s and the vault step dies.
355
+ // The op snapshot rides back in the envelope's `operation` field.
350
356
  const res = await setupFetch(
351
357
  hubUrl,
352
- `/api/modules/operations/${encodeURIComponent(opId)}`,
358
+ `/admin/setup?op=${encodeURIComponent(opId)}`,
353
359
  jar,
354
360
  fetchImpl,
355
361
  );
@@ -358,10 +364,11 @@ async function pollOperation(
358
364
  `op-poll failed (${res.status}) for ${shortLabel} op ${opId}: ${res.bodyText.slice(0, 200)}`,
359
365
  );
360
366
  }
361
- const body = res.json as Partial<OperationSnapshot> | undefined;
367
+ const envelope = res.json as { operation?: Partial<OperationSnapshot> } | undefined;
368
+ const body = envelope?.operation;
362
369
  if (!body || typeof body !== "object" || typeof body.id !== "string") {
363
370
  throw new Error(
364
- `op-poll returned unexpected body for ${shortLabel} op ${opId}: ${res.bodyText.slice(0, 200)}`,
371
+ `op-poll returned no operation snapshot for ${shortLabel} op ${opId}: ${res.bodyText.slice(0, 200)}`,
365
372
  );
366
373
  }
367
374
  // Print any new log lines since the last tick so the operator sees
@@ -1640,6 +1640,12 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1640
1640
  requireBootstrapToken: boolean;
1641
1641
  csrfToken: string;
1642
1642
  bootstrapToken?: string;
1643
+ operation?: {
1644
+ id: string;
1645
+ status: "pending" | "running" | "succeeded" | "failed";
1646
+ log: readonly string[];
1647
+ error?: string;
1648
+ };
1643
1649
  } = {
1644
1650
  step: state.step,
1645
1651
  hasAdmin: state.hasAdmin,
@@ -1648,6 +1654,25 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1648
1654
  requireBootstrapToken: requireToken,
1649
1655
  csrfToken: csrf.token,
1650
1656
  };
1657
+ // hub#616: the CLI wizard polls vault-provisioning over THIS session-authed
1658
+ // surface (mirroring the browser wizard's `/admin/setup?op=<id>` re-GET),
1659
+ // not the Bearer-gated `/api/modules/operations/:id` the SPA + install CLI
1660
+ // use. The wizard holds only a session cookie mid-setup; the op endpoint
1661
+ // requires a host-admin Bearer it doesn't have, so a direct poll 401s and
1662
+ // the vault step dies. Threading the op snapshot into the envelope keeps the
1663
+ // poll on the auth the wizard already carries.
1664
+ const opId = url.searchParams.get("op");
1665
+ if (opId) {
1666
+ const op = deps.registry?.get(opId);
1667
+ if (op) {
1668
+ envelope.operation = {
1669
+ id: op.id,
1670
+ status: op.status,
1671
+ log: op.log,
1672
+ ...(op.error !== undefined ? { error: op.error } : {}),
1673
+ };
1674
+ }
1675
+ }
1651
1676
  // hub#576: hand the actual token to a LOOPBACK caller only. The on-box
1652
1677
  // operator (`parachute init` → CLI wizard, or a curl from their own shell)
1653
1678
  // already proves box access by reaching loopback — same trust level as