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

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 (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -0,0 +1,372 @@
1
+ /**
2
+ * CLI wizard (`parachute setup-wizard`, hub#168 Cut 3). Exercises
3
+ * `runCliWizard` against a stub fetch + scripted prompts; no real hub
4
+ * required.
5
+ *
6
+ * The wizard's contract:
7
+ * 1. GET /admin/setup (Accept: application/json) → state envelope.
8
+ * 2. POST /admin/setup/account (application/json) → set-cookie + 200
9
+ * OK envelope.
10
+ * 3. POST /admin/setup/vault (application/json) → 200 OK envelope
11
+ * with `op_id`.
12
+ * 4. GET /api/modules/operations/<id> until terminal status.
13
+ * 5. POST /admin/setup/expose (application/json) → 200 OK.
14
+ *
15
+ * The stub fetch in this file is a mini-router that mimics the
16
+ * setup-wizard handlers' JSON-shape behavior — same fields, same
17
+ * cookies. It's not testing the wizard handler itself (setup-wizard.test
18
+ * does that); it's testing the CLI walks the right calls in the right
19
+ * order with the right payloads.
20
+ */
21
+
22
+ import { describe, expect, test } from "bun:test";
23
+ import { type VaultMode, parseWizardArgs, runCliWizard } from "../commands/wizard.ts";
24
+ import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
25
+ import { SESSION_COOKIE_NAME } from "../sessions.ts";
26
+
27
+ interface FakeHubState {
28
+ hasAdmin: boolean;
29
+ hasVault: boolean;
30
+ hasExposeMode: boolean;
31
+ vaultMode?: string;
32
+ importParams?: { remoteUrl: string; pat?: string; mode: string };
33
+ exposeMode?: string;
34
+ posted: Array<{ path: string; body: unknown }>;
35
+ }
36
+
37
+ function makeFakeHub(initialState?: Partial<FakeHubState>): {
38
+ state: FakeHubState;
39
+ // Loose return type — Bun's `typeof fetch` includes a `preconnect`
40
+ // method on its function-object signature that no fetch-mock needs,
41
+ // and our wizard only calls fetch as a function.
42
+ fetchImpl: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
43
+ } {
44
+ const state: FakeHubState = {
45
+ hasAdmin: false,
46
+ hasVault: false,
47
+ hasExposeMode: false,
48
+ posted: [],
49
+ ...initialState,
50
+ };
51
+ // Synthesize a stable CSRF token + session token for the stub. The
52
+ // real handlers re-derive these each request; the CLI just reads
53
+ // them back from Set-Cookie. Stub flows.
54
+ const csrf = "csrf-stub-token";
55
+ const session = "session-stub-id";
56
+ let opCount = 0;
57
+ const ops = new Map<
58
+ string,
59
+ {
60
+ id: string;
61
+ status: "pending" | "running" | "succeeded" | "failed";
62
+ log: string[];
63
+ error?: string;
64
+ }
65
+ >();
66
+
67
+ const fetchImpl = async (
68
+ input: string | URL | Request,
69
+ init?: RequestInit,
70
+ ): Promise<Response> => {
71
+ const url = typeof input === "string" ? new URL(input) : (input as URL);
72
+ const path = url.pathname + url.search;
73
+ const method = (init?.method ?? "GET").toUpperCase();
74
+ const headers = new Headers(init?.headers ?? {});
75
+ const body = init?.body;
76
+ const bodyJson: unknown = body ? JSON.parse(String(body)) : null;
77
+
78
+ // GET /admin/setup
79
+ if (path === "/admin/setup" && method === "GET") {
80
+ let step: "welcome" | "vault" | "expose" | "done" = "welcome";
81
+ if (state.hasAdmin && state.hasVault && state.hasExposeMode) step = "done";
82
+ else if (state.hasAdmin && state.hasVault) step = "expose";
83
+ else if (state.hasAdmin) step = "vault";
84
+ const respBody = JSON.stringify({
85
+ step,
86
+ hasAdmin: state.hasAdmin,
87
+ hasVault: state.hasVault,
88
+ hasExposeMode: state.hasExposeMode,
89
+ requireBootstrapToken: false,
90
+ csrfToken: csrf,
91
+ });
92
+ return new Response(respBody, {
93
+ status: 200,
94
+ headers: {
95
+ "content-type": "application/json; charset=utf-8",
96
+ "set-cookie": `${CSRF_COOKIE_NAME}=${csrf}`,
97
+ },
98
+ });
99
+ }
100
+
101
+ // POST /admin/setup/account
102
+ if (path === "/admin/setup/account" && method === "POST") {
103
+ state.posted.push({ path, body: bodyJson });
104
+ state.hasAdmin = true;
105
+ return new Response(JSON.stringify({ step: "vault", message: "admin created" }), {
106
+ status: 200,
107
+ headers: {
108
+ "content-type": "application/json; charset=utf-8",
109
+ "set-cookie": `${SESSION_COOKIE_NAME}=${session}`,
110
+ },
111
+ });
112
+ }
113
+
114
+ // POST /admin/setup/vault
115
+ if (path === "/admin/setup/vault" && method === "POST") {
116
+ state.posted.push({ path, body: bodyJson });
117
+ const b = bodyJson as {
118
+ mode?: string;
119
+ vault_name?: string;
120
+ remote_url?: string;
121
+ pat?: string;
122
+ import_mode?: string;
123
+ };
124
+ state.vaultMode = b.mode;
125
+ if (b.mode === "skip") {
126
+ state.hasVault = true;
127
+ return new Response(JSON.stringify({ step: "expose", message: "vault step skipped" }), {
128
+ status: 200,
129
+ headers: { "content-type": "application/json; charset=utf-8" },
130
+ });
131
+ }
132
+ if (b.mode === "import") {
133
+ state.importParams = {
134
+ remoteUrl: b.remote_url ?? "",
135
+ mode: b.import_mode ?? "merge",
136
+ ...(b.pat ? { pat: b.pat } : {}),
137
+ };
138
+ }
139
+ // Create an op + drive it to succeeded immediately for the test.
140
+ const opId = `op-test-${++opCount}`;
141
+ ops.set(opId, { id: opId, status: "succeeded", log: ["bun add -g", "spawned"] });
142
+ state.hasVault = true;
143
+ return new Response(JSON.stringify({ op_id: opId, step: "vault", mode: b.mode }), {
144
+ status: 200,
145
+ headers: { "content-type": "application/json; charset=utf-8" },
146
+ });
147
+ }
148
+
149
+ // GET /api/modules/operations/<id>
150
+ if (path.startsWith("/api/modules/operations/") && method === "GET") {
151
+ const id = path.slice("/api/modules/operations/".length);
152
+ const op = ops.get(id);
153
+ if (!op) {
154
+ return new Response(JSON.stringify({ error: "not found" }), {
155
+ status: 404,
156
+ headers: { "content-type": "application/json; charset=utf-8" },
157
+ });
158
+ }
159
+ return new Response(JSON.stringify(op), {
160
+ status: 200,
161
+ headers: { "content-type": "application/json; charset=utf-8" },
162
+ });
163
+ }
164
+
165
+ // POST /admin/setup/expose
166
+ if (path === "/admin/setup/expose" && method === "POST") {
167
+ state.posted.push({ path, body: bodyJson });
168
+ const b = bodyJson as { expose_mode?: string };
169
+ state.hasExposeMode = true;
170
+ state.exposeMode = b.expose_mode;
171
+ return new Response(JSON.stringify({ step: "done", message: "expose mode set" }), {
172
+ status: 200,
173
+ headers: { "content-type": "application/json; charset=utf-8" },
174
+ });
175
+ }
176
+
177
+ return new Response(`unhandled: ${method} ${path}`, { status: 500 });
178
+ };
179
+
180
+ return { state, fetchImpl };
181
+ }
182
+
183
+ describe("parseWizardArgs", () => {
184
+ test("requires --hub-url", () => {
185
+ const r = parseWizardArgs([]);
186
+ expect("error" in r).toBe(true);
187
+ if ("error" in r) expect(r.error).toContain("--hub-url");
188
+ });
189
+
190
+ test("parses canonical happy-path argv", () => {
191
+ const r = parseWizardArgs([
192
+ "--hub-url",
193
+ "http://127.0.0.1:1939",
194
+ "--account-username",
195
+ "admin",
196
+ "--account-password",
197
+ "longpassword",
198
+ "--vault-mode",
199
+ "create",
200
+ "--vault-name",
201
+ "default",
202
+ "--expose-mode",
203
+ "localhost",
204
+ ]);
205
+ expect("error" in r).toBe(false);
206
+ if ("error" in r) throw new Error(r.error);
207
+ expect(r.opts.hubUrl).toBe("http://127.0.0.1:1939");
208
+ expect(r.opts.accountUsername).toBe("admin");
209
+ expect(r.opts.accountPassword).toBe("longpassword");
210
+ expect(r.opts.vaultMode).toBe("create");
211
+ expect(r.opts.vaultName).toBe("default");
212
+ expect(r.opts.exposeMode).toBe("localhost");
213
+ });
214
+
215
+ test("rejects invalid vault-mode", () => {
216
+ const r = parseWizardArgs(["--hub-url", "http://x", "--vault-mode", "garbage"]);
217
+ expect("error" in r).toBe(true);
218
+ });
219
+
220
+ test("--skip-vault sets vaultMode=skip", () => {
221
+ const r = parseWizardArgs(["--hub-url", "http://x", "--skip-vault"]);
222
+ expect("error" in r).toBe(false);
223
+ if ("error" in r) throw new Error(r.error);
224
+ expect(r.opts.vaultMode).toBe("skip");
225
+ });
226
+
227
+ test("--vault-import-url infers vaultMode=import when not explicit", () => {
228
+ const r = parseWizardArgs([
229
+ "--hub-url",
230
+ "http://x",
231
+ "--vault-import-url",
232
+ "https://github.com/me/v.git",
233
+ ]);
234
+ expect("error" in r).toBe(false);
235
+ if ("error" in r) throw new Error(r.error);
236
+ expect(r.opts.vaultMode).toBe("import");
237
+ expect(r.opts.vaultImportRemoteUrl).toBe("https://github.com/me/v.git");
238
+ });
239
+ });
240
+
241
+ describe("runCliWizard", () => {
242
+ test("walks account → vault(create) → expose end-to-end and exits 0", async () => {
243
+ const { state, fetchImpl } = makeFakeHub();
244
+ const logs: string[] = [];
245
+ const code = await runCliWizard({
246
+ hubUrl: "http://127.0.0.1:1939",
247
+ log: (l) => logs.push(l),
248
+ fetchImpl,
249
+ sleep: async () => {},
250
+ accountUsername: "admin",
251
+ accountPassword: "longpassword",
252
+ vaultMode: "create",
253
+ vaultName: "default",
254
+ exposeMode: "localhost",
255
+ });
256
+ expect(code).toBe(0);
257
+ // Three POSTs in the right order: account, vault, expose.
258
+ expect(state.posted.map((p) => p.path)).toEqual([
259
+ "/admin/setup/account",
260
+ "/admin/setup/vault",
261
+ "/admin/setup/expose",
262
+ ]);
263
+ const accountBody = state.posted[0]?.body as Record<string, string>;
264
+ expect(accountBody.username).toBe("admin");
265
+ expect(accountBody.password).toBe("longpassword");
266
+ expect(accountBody[CSRF_FIELD_NAME]).toBe("csrf-stub-token");
267
+ const vaultBody = state.posted[1]?.body as Record<string, string>;
268
+ expect(vaultBody.mode).toBe("create");
269
+ expect(vaultBody.vault_name).toBe("default");
270
+ expect(state.exposeMode).toBe("localhost");
271
+ });
272
+
273
+ test("vault import mode threads remote_url + pat + import_mode", async () => {
274
+ const { state, fetchImpl } = makeFakeHub();
275
+ const code = await runCliWizard({
276
+ hubUrl: "http://127.0.0.1:1939",
277
+ log: () => {},
278
+ fetchImpl,
279
+ sleep: async () => {},
280
+ accountUsername: "admin",
281
+ accountPassword: "longpassword",
282
+ vaultMode: "import",
283
+ vaultName: "imported",
284
+ vaultImportRemoteUrl: "https://github.com/me/v.git",
285
+ vaultImportPat: "ghp_fake",
286
+ vaultImportReplace: true,
287
+ exposeMode: "localhost",
288
+ });
289
+ expect(code).toBe(0);
290
+ expect(state.importParams).toEqual({
291
+ remoteUrl: "https://github.com/me/v.git",
292
+ pat: "ghp_fake",
293
+ mode: "replace",
294
+ });
295
+ });
296
+
297
+ test("vault skip mode sends mode=skip + no name fields", async () => {
298
+ const { state, fetchImpl } = makeFakeHub();
299
+ const code = await runCliWizard({
300
+ hubUrl: "http://127.0.0.1:1939",
301
+ log: () => {},
302
+ fetchImpl,
303
+ sleep: async () => {},
304
+ accountUsername: "admin",
305
+ accountPassword: "longpassword",
306
+ vaultMode: "skip",
307
+ exposeMode: "localhost",
308
+ });
309
+ expect(code).toBe(0);
310
+ const vaultBody = state.posted[1]?.body as Record<string, string>;
311
+ expect(vaultBody.mode).toBe("skip");
312
+ expect(vaultBody.vault_name).toBeUndefined();
313
+ });
314
+
315
+ test("idempotent re-run picks up at the next undone step", async () => {
316
+ // Pre-seed: admin already exists. Wizard should skip account step
317
+ // and POST only vault + expose.
318
+ const { state, fetchImpl } = makeFakeHub({ hasAdmin: true });
319
+ const code = await runCliWizard({
320
+ hubUrl: "http://127.0.0.1:1939",
321
+ log: () => {},
322
+ fetchImpl,
323
+ sleep: async () => {},
324
+ vaultMode: "create",
325
+ vaultName: "default",
326
+ exposeMode: "localhost",
327
+ });
328
+ expect(code).toBe(0);
329
+ expect(state.posted.map((p) => p.path)).toEqual(["/admin/setup/vault", "/admin/setup/expose"]);
330
+ });
331
+
332
+ test("password mismatch when prompted exits non-zero", async () => {
333
+ const { fetchImpl } = makeFakeHub();
334
+ const prompts: string[] = [];
335
+ const answers = ["admin", "secretpassword", "different"];
336
+ const code = await runCliWizard({
337
+ hubUrl: "http://127.0.0.1:1939",
338
+ log: () => {},
339
+ fetchImpl,
340
+ sleep: async () => {},
341
+ prompt: async (q) => {
342
+ prompts.push(q);
343
+ return answers.shift() ?? "";
344
+ },
345
+ });
346
+ expect(code).toBe(1);
347
+ });
348
+
349
+ test("vault mode 'import' without remote_url errors via 400-equivalent flow", async () => {
350
+ const { fetchImpl } = makeFakeHub();
351
+ const code = await runCliWizard({
352
+ hubUrl: "http://127.0.0.1:1939",
353
+ log: () => {},
354
+ fetchImpl,
355
+ sleep: async () => {},
356
+ accountUsername: "admin",
357
+ accountPassword: "longpassword",
358
+ vaultMode: "import",
359
+ // No vaultImportRemoteUrl set + no prompt seam → the wizard will
360
+ // try to prompt for it. With no prompt seam configured, the
361
+ // default readline shim would block — but we never reach the
362
+ // prompt because the (test) fake handler accepts whatever
363
+ // arrives. So this test exercises the prompt-default path; we
364
+ // configure a prompt that returns "" (the user pressing Enter
365
+ // on the remote-URL question) → wizard sees empty, exits 1.
366
+ prompt: async () => "",
367
+ vaultName: "imported",
368
+ exposeMode: "localhost",
369
+ });
370
+ expect(code).toBe(1);
371
+ });
372
+ });