@openparachute/hub 0.5.13 → 0.5.14-rc.10

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 (101) hide show
  1. package/README.md +109 -15
  2. package/package.json +2 -2
  3. package/src/__tests__/account-home-ui.test.ts +205 -0
  4. package/src/__tests__/admin-handlers.test.ts +74 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  6. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  7. package/src/__tests__/admin-vaults.test.ts +70 -4
  8. package/src/__tests__/api-account.test.ts +191 -1
  9. package/src/__tests__/api-mint-token.test.ts +682 -3
  10. package/src/__tests__/api-modules-config.test.ts +16 -10
  11. package/src/__tests__/api-modules-ops.test.ts +97 -0
  12. package/src/__tests__/api-modules.test.ts +100 -83
  13. package/src/__tests__/api-ready.test.ts +135 -0
  14. package/src/__tests__/api-revoke-token.test.ts +384 -0
  15. package/src/__tests__/api-users.test.ts +390 -13
  16. package/src/__tests__/chrome-strip.test.ts +15 -15
  17. package/src/__tests__/cli.test.ts +7 -5
  18. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  19. package/src/__tests__/expose-auth-preflight.test.ts +58 -50
  20. package/src/__tests__/expose-cloudflare.test.ts +114 -3
  21. package/src/__tests__/expose-interactive.test.ts +10 -4
  22. package/src/__tests__/expose-public-auto.test.ts +5 -1
  23. package/src/__tests__/expose.test.ts +49 -1
  24. package/src/__tests__/hub-db.test.ts +194 -29
  25. package/src/__tests__/hub-server.test.ts +322 -33
  26. package/src/__tests__/hub.test.ts +11 -0
  27. package/src/__tests__/init.test.ts +827 -0
  28. package/src/__tests__/lifecycle.test.ts +33 -1
  29. package/src/__tests__/migrate.test.ts +433 -51
  30. package/src/__tests__/notes-redirect.test.ts +20 -20
  31. package/src/__tests__/oauth-handlers.test.ts +1060 -29
  32. package/src/__tests__/oauth-ui.test.ts +12 -1
  33. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  34. package/src/__tests__/proxy-state.test.ts +192 -0
  35. package/src/__tests__/resource-binding.test.ts +97 -0
  36. package/src/__tests__/scope-explanations.test.ts +36 -0
  37. package/src/__tests__/serve.test.ts +9 -9
  38. package/src/__tests__/services-manifest.test.ts +40 -40
  39. package/src/__tests__/setup-wizard.test.ts +1114 -66
  40. package/src/__tests__/setup.test.ts +1 -1
  41. package/src/__tests__/status.test.ts +39 -0
  42. package/src/__tests__/users.test.ts +396 -9
  43. package/src/__tests__/vault-auth-status.test.ts +271 -11
  44. package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
  45. package/src/__tests__/well-known.test.ts +9 -9
  46. package/src/__tests__/wizard.test.ts +372 -0
  47. package/src/account-home-ui.ts +547 -0
  48. package/src/admin-handlers.ts +49 -17
  49. package/src/admin-host-admin-token.ts +25 -0
  50. package/src/admin-login-ui.ts +4 -4
  51. package/src/admin-vault-admin-token.ts +17 -0
  52. package/src/admin-vaults.ts +48 -15
  53. package/src/api-account.ts +72 -6
  54. package/src/api-mint-token.ts +132 -24
  55. package/src/api-modules-ops.ts +52 -16
  56. package/src/api-modules.ts +31 -14
  57. package/src/api-ready.ts +102 -0
  58. package/src/api-revoke-token.ts +107 -21
  59. package/src/api-users.ts +497 -58
  60. package/src/bun-link.ts +55 -0
  61. package/src/chrome-strip.ts +6 -6
  62. package/src/cli.ts +93 -24
  63. package/src/cloudflare/config.ts +10 -4
  64. package/src/cloudflare/detect.ts +73 -6
  65. package/src/commands/expose-auth-preflight.ts +55 -63
  66. package/src/commands/expose-cloudflare.ts +114 -10
  67. package/src/commands/expose-interactive.ts +10 -11
  68. package/src/commands/expose-public-auto.ts +6 -4
  69. package/src/commands/expose.ts +8 -0
  70. package/src/commands/init.ts +563 -0
  71. package/src/commands/install.ts +41 -23
  72. package/src/commands/lifecycle.ts +12 -0
  73. package/src/commands/migrate.ts +293 -41
  74. package/src/commands/status.ts +10 -1
  75. package/src/commands/wizard.ts +843 -0
  76. package/src/env-file.ts +10 -0
  77. package/src/help.ts +157 -17
  78. package/src/hub-db.ts +42 -0
  79. package/src/hub-server.ts +136 -23
  80. package/src/hub-settings.ts +13 -2
  81. package/src/hub.ts +16 -9
  82. package/src/notes-redirect.ts +5 -5
  83. package/src/oauth-handlers.ts +342 -173
  84. package/src/oauth-ui.ts +28 -2
  85. package/src/proxy-error-ui.ts +506 -0
  86. package/src/proxy-state.ts +131 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +94 -5
  90. package/src/service-spec.ts +39 -18
  91. package/src/setup-wizard.ts +1173 -117
  92. package/src/users.ts +307 -29
  93. package/src/vault/auth-status.ts +152 -25
  94. package/src/vault-hub-origin-env.ts +100 -0
  95. package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
  96. package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  99. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  100. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  101. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
package/README.md CHANGED
@@ -61,33 +61,122 @@ Operators who want env-var-driven seeding (CI, scripted deploys) can still set `
61
61
 
62
62
  ## First 5 minutes
63
63
 
64
+ One command gets you from a fresh install to the setup wizard:
65
+
64
66
  ```sh
65
67
  # 1. Install the hub (one line — installs the `parachute` binary)
66
68
  bun add -g @openparachute/hub
67
69
 
68
- # 2. Install a service (runs `bun add -g @openparachute/vault` + `parachute-vault init`)
69
- parachute install vault
70
+ # 2. parachute init the unified front door (laptop, EC2, any VPS).
71
+ # It starts the hub, offers to expose it, always installs the vault
72
+ # module, then drops you into the setup wizard.
73
+ parachute init
74
+ ```
70
75
 
71
- # 3. Start the service in the background (PID + logs tracked under ~/.parachute/vault/)
72
- parachute start vault
76
+ `parachute init` is idempotent every re-run is safe. End to end it:
77
+
78
+ 1. **Starts the hub** if it isn't already running (port `1939`).
79
+ 2. **Offers to expose it** so you can reach the wizard from other devices. In a
80
+ terminal you pick: stay loopback-only, your **tailnet** (`tailscale serve` —
81
+ private to your own Tailscale devices), or a **Cloudflare Tunnel** (public
82
+ HTTPS on your own domain). The default highlights "no thanks — loopback" on a
83
+ laptop and pre-selects Cloudflare on an SSH'd server. Skip with
84
+ `--no-expose-prompt`, or pin non-interactively with
85
+ `--expose none|tailnet|cloudflare`.
86
+ 3. **Installs the vault module** — always — so the wizard can offer
87
+ create / import / skip. No vault *instance* is created yet; that's the
88
+ wizard's call.
89
+ 4. **Drops you into the setup wizard.** Browser by default (opens
90
+ `/admin/setup`); pick the in-terminal walk-through with `--cli-wizard`, or
91
+ force the browser with `--browser-wizard`. It prints the canonical admin URL
92
+ either way — loopback when you're not exposed, the tailnet / Cloudflare FQDN
93
+ when you are.
94
+
95
+ The wizard walks the same three steps in the browser and the CLI:
96
+
97
+ - **Account** — create the admin operator for this hub (username + password).
98
+ - **Vault** — *create* a fresh vault (default name `default`), *import* one from
99
+ a git repo (a previously-exported Parachute vault on any HTTPS / SSH remote;
100
+ PAT optional for private repos), or *skip* and create one later. The vault
101
+ module is installed regardless of which you pick.
102
+ - **Expose** — record how this hub is reached (localhost / tailnet / public) so
103
+ the done screen surfaces the right URLs.
104
+
105
+ The done screen hands you a copy-pasteable `claude mcp add` command (with a
106
+ freshly-minted operator token), a link to start using your vault, and the admin
107
+ UI. Verify the stack any time:
73
108
 
74
- # 4. Check it landed — reads ~/.parachute/services.json, shows process state + probes health
109
+ ```sh
75
110
  parachute status
76
111
  # SERVICE PORT VERSION PROCESS PID UPTIME HEALTH LATENCY
77
- # parachute-vault 1940 0.2.4 running 12345 12s ok 2ms
112
+ # parachute-hub 1939 0.5.14 running 12344 20s ok 1ms
113
+ # parachute-vault 1940 0.4.5 running 12345 12s ok 2ms
114
+ ```
78
115
 
79
- # 5. Use it. Vault is up on 127.0.0.1:1940; Claude Code picked up the MCP
80
- # on your next session. Point any other local MCP client (Codex, Goose,
81
- # OpenCode, Cursor, Zed, Cline, your own agent) at:
82
- # http://127.0.0.1:1940/vault/default/mcp
116
+ Vault is up on `127.0.0.1:1940`; Claude Code picks up the MCP on your next
117
+ session. Point any other local MCP client (Codex, Goose, OpenCode, Cursor, Zed,
118
+ Cline, your own agent) at `http://127.0.0.1:1940/vault/<name>/mcp`.
83
119
 
84
- # 6. Expose across your tailnet HTTPS, MagicDNS, only your devices.
85
- # The supported exposure shape today; public-internet exposure is
86
- # exploratory (see "Public exposure" below).
87
- parachute expose tailnet
120
+ ### Want the wizard in the terminal instead of the browser?
121
+
122
+ ```sh
123
+ parachute init --cli-wizard
88
124
  ```
89
125
 
90
- Tear down with `parachute expose tailnet off`. The public layer (`expose public off`) tears down independently — `off` only affects the layer you name.
126
+ …or drive the wizard directly against an already-running hub:
127
+
128
+ ```sh
129
+ parachute setup-wizard --hub-url http://127.0.0.1:1939
130
+ ```
131
+
132
+ `setup-wizard` is the in-terminal mirror of `/admin/setup` — same handlers, same
133
+ Account → Vault → Expose walk. Every prompt has a paired flag for scripted /
134
+ non-interactive setup (`--account-username`, `--account-password`,
135
+ `--vault-mode create|import|skip`, `--vault-name`, `--vault-import-url`,
136
+ `--expose-mode localhost|tailnet|public`, …); run `parachute setup-wizard --help`
137
+ for the full list.
138
+
139
+ ### Prefer to drive installs by hand?
140
+
141
+ `parachute init` → wizard is the recommended path, but the per-module commands
142
+ still work and are additive:
143
+
144
+ ```sh
145
+ parachute install vault # install + register + create first vault + start one module
146
+ parachute setup # older interactive multi-pick: survey + install vault/notes/scribe
147
+ parachute start vault # PID + logs tracked under ~/.parachute/vault/
148
+ ```
149
+
150
+ ### Expose across your tailnet
151
+
152
+ ```sh
153
+ parachute expose tailnet # HTTPS, MagicDNS, only your devices (the supported shape today)
154
+ ```
155
+
156
+ Tear down with `parachute expose tailnet off`. The public layer (`expose public off`) tears down independently — `off` only affects the layer you name. Public-internet exposure is exploratory (see "Public exposure" below).
157
+
158
+ ### Onboarding a team member
159
+
160
+ Want to give a friend or teammate their own account on your hub? The flow is
161
+ self-service end to end:
162
+
163
+ 1. **Create the user.** In the admin UI, open **Users → Create User**: pick a
164
+ username, set a temporary password, and (optionally) assign one or more
165
+ vaults. The success banner echoes the exact sign-in URL.
166
+ 2. **Hand them three things:** your hub's sign-in URL (`<hub-origin>/login`),
167
+ their username, and the temporary password you set.
168
+ 3. **They sign in and set their own password.** On first sign-in they're
169
+ prompted to change it — they can't reach the rest of the hub until they do.
170
+ 4. **Later changes are self-service.** A signed-in user changes their password
171
+ anytime from their account home at `/account/` (reachable via the **Account**
172
+ link in the top-right once signed in).
173
+ 5. **You can reset it for them.** If they're locked out, the **Reset password**
174
+ button on the Users row sets a new temporary password and re-arms the
175
+ force-change-on-next-sign-in prompt.
176
+
177
+ The first admin (the wizard / env-seeded account) is unrestricted and can't be
178
+ deleted or reset from the Users page — it changes its own password at
179
+ `/account/change-password` directly.
91
180
 
92
181
  ## Service lifecycle
93
182
 
@@ -313,6 +402,11 @@ Public-internet exposure (`parachute expose public`) is exploratory — see "Pub
313
402
  Run `parachute --help` for the top-level list, and `parachute <subcommand> --help` for details on any individual command.
314
403
 
315
404
  ```
405
+ parachute init fresh-install front door: start hub, offer expose,
406
+ install vault module, open the setup wizard
407
+ parachute setup-wizard --hub-url <url>
408
+ in-terminal mirror of /admin/setup (Account/Vault/Expose)
409
+ parachute setup older interactive multi-pick service installer
316
410
  parachute install <service> install and register a service
317
411
  parachute status show installed services, process state, health
318
412
  parachute start [service] start services in the background
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.13",
4
- "description": "parachute the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
3
+ "version": "0.5.14-rc.10",
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": {
7
7
  "access": "public"
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Renderer tests for the friend-facing `/account/` home (multi-user
3
+ * Phase 1 follow-up). The page is a pure function over its opts — these
4
+ * tests pin the load-bearing shape:
5
+ *
6
+ * - Assigned-vault branch: Notes CTA href encodes the hub+vault URL,
7
+ * vault name shows in the body, AND a per-tile MCP connect block
8
+ * surfaces the endpoint + `claude mcp add` command (OAuth, no token)
9
+ * with copy buttons — the multi-user friend-connect surface.
10
+ * - Admin (no assigned vault) branch: link to /admin/ visible.
11
+ * - Defensive third branch (non-admin + no vault): "ask the operator"
12
+ * copy renders.
13
+ * - Common scaffolding: username in welcome, sign-out POST form,
14
+ * change-password link.
15
+ */
16
+ import { describe, expect, test } from "bun:test";
17
+ import {
18
+ accountClaudeMcpAddCommand,
19
+ accountMcpEndpoint,
20
+ renderAccountHome,
21
+ } from "../account-home-ui.ts";
22
+
23
+ const HUB_ORIGIN = "https://hub.example";
24
+ const CSRF = "test-csrf-token";
25
+
26
+ describe("renderAccountHome", () => {
27
+ test("assigned-vault branch — Notes CTA carries the encoded hub+vault URL", () => {
28
+ const html = renderAccountHome({
29
+ username: "alice",
30
+ assignedVaults: ["alice"],
31
+ passwordChanged: true,
32
+ hubOrigin: HUB_ORIGIN,
33
+ isFirstAdmin: false,
34
+ csrfToken: CSRF,
35
+ });
36
+ // Welcome header includes the username.
37
+ expect(html).toContain("Welcome, alice");
38
+ // Vault name renders as inline content in the vault card.
39
+ expect(html).toContain("<strong>alice</strong>");
40
+ // Notes "Open" CTA — same shape as setup-wizard's renderStartUsingTile.
41
+ // The href encodes `${hubOrigin}/vault/<name>` via encodeURIComponent.
42
+ const encodedVaultUrl = encodeURIComponent(`${HUB_ORIGIN}/vault/alice`);
43
+ expect(html).toContain(`https://notes.parachute.computer/add?url=${encodedVaultUrl}`);
44
+ expect(html).toContain('target="_blank"');
45
+ expect(html).toContain('rel="noopener"');
46
+ // The friend-connect surface: MCP endpoint + `claude mcp add` command,
47
+ // each with a copy button. OAuth path (no token in the command).
48
+ expect(html).toContain(`${HUB_ORIGIN}/vault/alice/mcp`);
49
+ expect(html).toContain(
50
+ `claude mcp add --transport http parachute-alice ${HUB_ORIGIN}/vault/alice/mcp`,
51
+ );
52
+ expect(html).toContain('data-testid="copy-mcp-endpoint"');
53
+ expect(html).toContain('data-testid="copy-mcp-add-command"');
54
+ // The connect command must NOT embed a token — the OAuth path needs none.
55
+ expect(html).not.toContain("--header");
56
+ expect(html).not.toContain("Authorization: Bearer");
57
+ // Copy-button progressive-enhancement script is present.
58
+ expect(html).toContain("navigator.clipboard");
59
+ });
60
+
61
+ test("assigned-vault branch — trailing slash on hubOrigin is normalized", () => {
62
+ // The handler resolves origin per-request via `resolveIssuer`; some
63
+ // operators set a hub_origin setting with a trailing slash. The
64
+ // renderer must produce a clean `/vault/<name>` join either way.
65
+ const html = renderAccountHome({
66
+ username: "alice",
67
+ assignedVaults: ["alice"],
68
+ passwordChanged: true,
69
+ hubOrigin: `${HUB_ORIGIN}/`,
70
+ isFirstAdmin: false,
71
+ csrfToken: CSRF,
72
+ });
73
+ const cleanEncoded = encodeURIComponent(`${HUB_ORIGIN}/vault/alice`);
74
+ expect(html).toContain(`https://notes.parachute.computer/add?url=${cleanEncoded}`);
75
+ // The MCP endpoint + connect command also drop the trailing slash — no
76
+ // `//vault` double-slash sneaks in.
77
+ expect(html).toContain(`${HUB_ORIGIN}/vault/alice/mcp`);
78
+ expect(html).not.toContain(`${HUB_ORIGIN}//vault`);
79
+ });
80
+
81
+ test("admin branch — null assignedVault + isFirstAdmin renders an /admin/ link", () => {
82
+ const html = renderAccountHome({
83
+ username: "admin",
84
+ assignedVaults: [],
85
+ passwordChanged: true,
86
+ hubOrigin: HUB_ORIGIN,
87
+ isFirstAdmin: true,
88
+ csrfToken: CSRF,
89
+ });
90
+ expect(html).toContain("Welcome, admin");
91
+ expect(html).toContain("hub administrator");
92
+ expect(html).toContain('href="/admin/"');
93
+ // Should NOT carry the Notes CTA (no vault).
94
+ expect(html).not.toContain("notes.parachute.computer/add");
95
+ });
96
+
97
+ test("defensive branch — non-admin with null assignedVault renders an 'ask operator' message", () => {
98
+ // Shouldn't normally occur in Phase 1 (PR 2's /api/users always
99
+ // assigns a vault on create), but the renderer carries a clear
100
+ // explanation rather than a blank card if a row gets into that
101
+ // state via hand-edit or migration race.
102
+ const html = renderAccountHome({
103
+ username: "ghost",
104
+ assignedVaults: [],
105
+ passwordChanged: true,
106
+ hubOrigin: HUB_ORIGIN,
107
+ isFirstAdmin: false,
108
+ csrfToken: CSRF,
109
+ });
110
+ expect(html).toContain("Welcome, ghost");
111
+ expect(html).toContain("Ask the hub operator");
112
+ // No /admin/ link in this branch — they have no admin role.
113
+ expect(html).not.toContain('href="/admin/"');
114
+ // No Notes CTA.
115
+ expect(html).not.toContain("notes.parachute.computer/add");
116
+ });
117
+
118
+ test("account card — change-password link and sign-out form are present", () => {
119
+ const html = renderAccountHome({
120
+ username: "alice",
121
+ assignedVaults: ["alice"],
122
+ passwordChanged: true,
123
+ hubOrigin: HUB_ORIGIN,
124
+ isFirstAdmin: false,
125
+ csrfToken: CSRF,
126
+ });
127
+ // Change-password link points at the existing /account/change-password
128
+ // route (server-rendered HTML, separate handler).
129
+ expect(html).toContain('href="/account/change-password"');
130
+ // Sign-out form POSTs to /logout (existing handler), CSRF token
131
+ // round-trips via the renderCsrfHiddenInput helper.
132
+ expect(html).toContain('action="/logout"');
133
+ expect(html).toContain('method="POST"');
134
+ expect(html).toContain(CSRF);
135
+ // Username renders inside the account card too.
136
+ expect(html).toContain("<code>alice</code>");
137
+ });
138
+
139
+ test("multi-vault branch — renders one tile per assigned vault (Phase 2 PR 2)", () => {
140
+ const html = renderAccountHome({
141
+ username: "alice",
142
+ assignedVaults: ["personal", "family"],
143
+ passwordChanged: true,
144
+ hubOrigin: HUB_ORIGIN,
145
+ isFirstAdmin: false,
146
+ csrfToken: CSRF,
147
+ });
148
+ // Plural heading.
149
+ expect(html).toContain("Your vaults");
150
+ // Each vault name appears.
151
+ expect(html).toContain("<strong>personal</strong>");
152
+ expect(html).toContain("<strong>family</strong>");
153
+ // One CTA per vault with the right encoded URL.
154
+ const personalEncoded = encodeURIComponent(`${HUB_ORIGIN}/vault/personal`);
155
+ const familyEncoded = encodeURIComponent(`${HUB_ORIGIN}/vault/family`);
156
+ expect(html).toContain(`https://notes.parachute.computer/add?url=${personalEncoded}`);
157
+ expect(html).toContain(`https://notes.parachute.computer/add?url=${familyEncoded}`);
158
+ // One per-vault MCP connect block per tile (endpoint + command).
159
+ expect(html).toContain(`${HUB_ORIGIN}/vault/personal/mcp`);
160
+ expect(html).toContain(`${HUB_ORIGIN}/vault/family/mcp`);
161
+ expect(html).toContain(
162
+ `claude mcp add --transport http parachute-personal ${HUB_ORIGIN}/vault/personal/mcp`,
163
+ );
164
+ expect(html).toContain(
165
+ `claude mcp add --transport http parachute-family ${HUB_ORIGIN}/vault/family/mcp`,
166
+ );
167
+ // Two tiles → two copy-endpoint buttons.
168
+ expect(html.split('data-testid="copy-mcp-endpoint"').length - 1).toBe(2);
169
+ // The copy script is emitted once at the section level, not per-tile.
170
+ expect(html.split("<script>").length - 1).toBe(1);
171
+ });
172
+
173
+ test("accountMcpEndpoint / accountClaudeMcpAddCommand build the canonical shapes", () => {
174
+ expect(accountMcpEndpoint("https://hub.example", "work")).toBe(
175
+ "https://hub.example/vault/work/mcp",
176
+ );
177
+ expect(accountClaudeMcpAddCommand("https://hub.example", "work")).toBe(
178
+ "claude mcp add --transport http parachute-work https://hub.example/vault/work/mcp",
179
+ );
180
+ });
181
+
182
+ test("escapes hostile content in username and vault name", () => {
183
+ // Defense-in-depth: usernames pass validateUsername (lowercase alnum
184
+ // + `_-`), so HTML metacharacters won't normally make it through. But
185
+ // the renderer is a pure function over arbitrary string input and the
186
+ // escape is load-bearing if the validator ever loosens.
187
+ const html = renderAccountHome({
188
+ username: "<script>alert(1)</script>",
189
+ assignedVaults: ["<vault>"],
190
+ passwordChanged: true,
191
+ hubOrigin: HUB_ORIGIN,
192
+ isFirstAdmin: false,
193
+ csrfToken: CSRF,
194
+ });
195
+ // The injected username/vault metacharacters are escaped — the only
196
+ // `<script>` tag in the output is the page's own copy-button helper, so
197
+ // we assert on the injected payload specifically rather than a blanket
198
+ // "no <script>" (the connect block legitimately emits one).
199
+ expect(html).not.toContain("<script>alert(1)</script>");
200
+ expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
201
+ expect(html).toContain("&lt;vault&gt;");
202
+ // The escaped vault name also flows into the connect command + endpoint.
203
+ expect(html).toContain("parachute-&lt;vault&gt;");
204
+ });
205
+ });
@@ -426,6 +426,80 @@ describe("loginRedirectTarget — force-change-password (multi-user PR 3)", () =
426
426
  expect(res.headers.get("location")).toBe("/admin/tokens");
427
427
  });
428
428
 
429
+ test("non-admin (friend) targeting /admin/* gets rewritten to /account/", async () => {
430
+ // Multi-user follow-up: a signed-in friend account hitting an /admin/*
431
+ // URL via post-login `next=` would otherwise land on the admin SPA,
432
+ // which 403s on `/admin/host-admin-token` (first-admin gate) and
433
+ // bounces them back via the SPA-side 403 handler. Rewriting at the
434
+ // login boundary skips the bouncing-around UX. Friend has
435
+ // passwordChanged=true so the force-change path doesn't pre-empt.
436
+ await createUser(harness.db, "admin", "admin-pw", { passwordChanged: true });
437
+ await createUser(harness.db, "alice", "alice-pw", {
438
+ allowMulti: true,
439
+ passwordChanged: true,
440
+ });
441
+ const { body, headers } = formBody({
442
+ [CSRF_FIELD_NAME]: TEST_CSRF,
443
+ username: "alice",
444
+ password: "alice-pw",
445
+ next: "/admin/users",
446
+ });
447
+ const req = new Request("http://hub.test/login", {
448
+ method: "POST",
449
+ headers: { ...headers, cookie: CSRF_COOKIE },
450
+ body,
451
+ });
452
+ const res = await handleAdminLoginPost(harness.db, req);
453
+ expect(res.status).toBe(302);
454
+ expect(res.headers.get("location")).toBe("/account/");
455
+ });
456
+
457
+ test("admin targeting /admin/* still lands at the original next= (rewrite doesn't fire for admin)", async () => {
458
+ // Belt-and-suspenders for the friend rewrite above: the same next=
459
+ // for the admin user must NOT redirect to /account/.
460
+ await createUser(harness.db, "admin", "admin-pw", { passwordChanged: true });
461
+ const { body, headers } = formBody({
462
+ [CSRF_FIELD_NAME]: TEST_CSRF,
463
+ username: "admin",
464
+ password: "admin-pw",
465
+ next: "/admin/users",
466
+ });
467
+ const req = new Request("http://hub.test/login", {
468
+ method: "POST",
469
+ headers: { ...headers, cookie: CSRF_COOKIE },
470
+ body,
471
+ });
472
+ const res = await handleAdminLoginPost(harness.db, req);
473
+ expect(res.status).toBe(302);
474
+ expect(res.headers.get("location")).toBe("/admin/users");
475
+ });
476
+
477
+ test("non-admin (friend) keeps non-/admin next= intact (e.g. /oauth/authorize, /vault/...)", async () => {
478
+ // Legitimate user destinations — OAuth consent + per-vault routes —
479
+ // must pass through unchanged. Friends sign in through /login as part
480
+ // of the OAuth user-consent flow; rewriting their next= to /account/
481
+ // would break that.
482
+ await createUser(harness.db, "admin", "admin-pw", { passwordChanged: true });
483
+ await createUser(harness.db, "alice", "alice-pw", {
484
+ allowMulti: true,
485
+ passwordChanged: true,
486
+ });
487
+ const { body, headers } = formBody({
488
+ [CSRF_FIELD_NAME]: TEST_CSRF,
489
+ username: "alice",
490
+ password: "alice-pw",
491
+ next: "/oauth/authorize?client_id=abc",
492
+ });
493
+ const req = new Request("http://hub.test/login", {
494
+ method: "POST",
495
+ headers: { ...headers, cookie: CSRF_COOKIE },
496
+ body,
497
+ });
498
+ const res = await handleAdminLoginPost(harness.db, req);
499
+ expect(res.status).toBe(302);
500
+ expect(res.headers.get("location")).toBe("/oauth/authorize?client_id=abc");
501
+ });
502
+
429
503
  test("password_changed=false defense-in-depth: unsafe next= is sanitized before encoding", async () => {
430
504
  // safeNext rewrites unsafe next values to /admin/vaults BEFORE the
431
505
  // redirect-target helper runs. The change-password URL should never
@@ -56,6 +56,31 @@ async function withSession(): Promise<{ cookie: string; userId: string }> {
56
56
  return { cookie, userId: user.id };
57
57
  }
58
58
 
59
+ /**
60
+ * Seed an admin (first-created user) + a second non-admin "friend"
61
+ * account, return cookies + ids for both. Used by the first-admin-gate
62
+ * tests.
63
+ */
64
+ async function withAdminAndFriend(): Promise<{
65
+ adminCookie: string;
66
+ adminId: string;
67
+ friendCookie: string;
68
+ friendId: string;
69
+ }> {
70
+ const admin = await createUser(harness.db, "admin", "admin-passphrase");
71
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", {
72
+ allowMulti: true,
73
+ });
74
+ const adminSession = createSession(harness.db, { userId: admin.id });
75
+ const friendSession = createSession(harness.db, { userId: friend.id });
76
+ return {
77
+ adminCookie: buildSessionCookie(adminSession.id, Math.floor(SESSION_TTL_MS / 1000)),
78
+ adminId: admin.id,
79
+ friendCookie: buildSessionCookie(friendSession.id, Math.floor(SESSION_TTL_MS / 1000)),
80
+ friendId: friend.id,
81
+ };
82
+ }
83
+
59
84
  describe("handleHostAdminToken", () => {
60
85
  test("401 when no session cookie is present", async () => {
61
86
  const req = new Request(`${ISSUER}/admin/host-admin-token`);
@@ -124,6 +149,43 @@ describe("handleHostAdminToken", () => {
124
149
  expect(scopes).toContain("parachute:host:auth");
125
150
  });
126
151
 
152
+ test("403 not_admin when a signed-in non-first-admin (friend) hits the endpoint", async () => {
153
+ // Privesc closure: without the first-admin gate, any signed-in
154
+ // friend account could mint a JWT carrying parachute:host:admin +
155
+ // parachute:host:auth — the SPA bearer that gates vault provisioning,
156
+ // grants, and the token registry. The friend's session is valid;
157
+ // the endpoint must refuse because the session.userId doesn't match
158
+ // the first-admin row.
159
+ const { friendCookie } = await withAdminAndFriend();
160
+ rotateSigningKey(harness.db);
161
+ const req = new Request(`${ISSUER}/admin/host-admin-token`, {
162
+ headers: { cookie: friendCookie },
163
+ });
164
+ const res = await handleHostAdminToken(req, { db: harness.db, issuer: ISSUER });
165
+ expect(res.status).toBe(403);
166
+ const body = (await res.json()) as { error: string; error_description: string };
167
+ expect(body.error).toBe("not_admin");
168
+ // The wire description steers the SPA-side handler toward /account/.
169
+ expect(body.error_description).toContain("/account/");
170
+ });
171
+
172
+ test("first-admin path still succeeds when a friend exists alongside", async () => {
173
+ // Belt-and-suspenders for the gate above: adding a second user must
174
+ // not break the admin's own happy path. Same DB, same query — the
175
+ // admin's session.userId matches the earliest-created row, so the
176
+ // gate passes.
177
+ const { adminCookie, adminId } = await withAdminAndFriend();
178
+ rotateSigningKey(harness.db);
179
+ const req = new Request(`${ISSUER}/admin/host-admin-token`, {
180
+ headers: { cookie: adminCookie },
181
+ });
182
+ const res = await handleHostAdminToken(req, { db: harness.db, issuer: ISSUER });
183
+ expect(res.status).toBe(200);
184
+ const body = (await res.json()) as { token: string };
185
+ const validated = await validateAccessToken(harness.db, body.token, ISSUER);
186
+ expect(validated.payload.sub).toBe(adminId);
187
+ });
188
+
127
189
  // Regression for the end-to-end bug that motivated adding `:host:auth`
128
190
  // here: the SPA's session-bearer was rejected by `/api/auth/tokens` (and
129
191
  // its peers) because it carried `:host:admin` only. This test mints
@@ -152,6 +152,50 @@ describe("handleVaultAdminToken", () => {
152
152
  expect(scopeClaim.split(/\s+/)).toContain("vault:work:admin");
153
153
  });
154
154
 
155
+ test("403 not_admin when the session belongs to a non-first-admin user", async () => {
156
+ // Multi-user Phase 1 privesc gate (mirrors host-admin-token). The first-
157
+ // created user is the hub admin; subsequent users are friends pinned to
158
+ // a single vault and must NOT be able to mint vault:<name>:admin via
159
+ // this endpoint. The session check alone would let them — that's the
160
+ // whole reason this gate exists.
161
+ await createUser(harness.db, "operator", "hunter2-admin");
162
+ const friend = await createUser(harness.db, "friend", "hunter2-friend", {
163
+ assignedVaults: ["work"],
164
+ allowMulti: true,
165
+ });
166
+ const session = createSession(harness.db, { userId: friend.id });
167
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
168
+ const req = new Request(`${ISSUER}/admin/vault-admin-token/work`, { headers: { cookie } });
169
+ const res = await handleVaultAdminToken(req, "work", {
170
+ db: harness.db,
171
+ issuer: ISSUER,
172
+ knownVaultNames: known("work", "default"),
173
+ });
174
+ expect(res.status).toBe(403);
175
+ const body = (await res.json()) as { error: string; error_description: string };
176
+ expect(body.error).toBe("not_admin");
177
+ expect(body.error_description).toContain("/account/");
178
+ });
179
+
180
+ test("first admin still mints when other users exist", async () => {
181
+ // Regression: the first-admin gate must not regress the operator's
182
+ // happy path when there are friends in the DB.
183
+ const admin = await createUser(harness.db, "operator", "hunter2-admin");
184
+ await createUser(harness.db, "friend", "hunter2-friend", {
185
+ assignedVaults: ["work"],
186
+ allowMulti: true,
187
+ });
188
+ const session = createSession(harness.db, { userId: admin.id });
189
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
190
+ const req = new Request(`${ISSUER}/admin/vault-admin-token/work`, { headers: { cookie } });
191
+ const res = await handleVaultAdminToken(req, "work", {
192
+ db: harness.db,
193
+ issuer: ISSUER,
194
+ knownVaultNames: known("work"),
195
+ });
196
+ expect(res.status).toBe(200);
197
+ });
198
+
155
199
  test("audience is per-vault — different vaults get different aud claims", async () => {
156
200
  // Regression for PR #173 follow-up: a single shared audience constant
157
201
  // meant a token minted for vault `boulder` carried `aud: "hub"` and