@openparachute/hub 0.5.14-rc.9 → 0.6.0
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/README.md +23 -0
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +30 -21
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +482 -14
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +97 -0
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +102 -1
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +41 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +335 -15
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +47 -6
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-modules-ops.ts +49 -11
- package/src/api-users.ts +29 -3
- package/src/cli.ts +26 -21
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +39 -44
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +370 -12
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +8 -4
- package/src/env-file.ts +10 -0
- package/src/help.ts +3 -1
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +52 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-explanations.ts +46 -18
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +77 -7
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +71 -19
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
package/README.md
CHANGED
|
@@ -155,6 +155,29 @@ parachute expose tailnet # HTTPS, MagicDNS, only your devices (the supported sh
|
|
|
155
155
|
|
|
156
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
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.
|
|
180
|
+
|
|
158
181
|
## Service lifecycle
|
|
159
182
|
|
|
160
183
|
`parachute start`, `stop`, `restart`, and `logs` manage services as background processes — no launchd, no manual `bun serve`, no hunting for PIDs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/hub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "parachute \u2014 the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"publishConfig": {
|
|
@@ -37,13 +37,17 @@
|
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@biomejs/biome": "^1.9.4",
|
|
40
|
-
"@types/bun": "latest"
|
|
40
|
+
"@types/bun": "latest",
|
|
41
|
+
"@types/qrcode": "^1.5.6"
|
|
41
42
|
},
|
|
42
43
|
"peerDependencies": {
|
|
43
44
|
"typescript": "^5"
|
|
44
45
|
},
|
|
45
46
|
"dependencies": {
|
|
46
47
|
"@node-rs/argon2": "^2.0.2",
|
|
47
|
-
"
|
|
48
|
+
"@openparachute/depcheck": "0.1.0",
|
|
49
|
+
"jose": "^6.2.2",
|
|
50
|
+
"otpauth": "^9.5.0",
|
|
51
|
+
"qrcode": "^1.5.4"
|
|
48
52
|
}
|
|
49
53
|
}
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* tests pin the load-bearing shape:
|
|
5
5
|
*
|
|
6
6
|
* - Assigned-vault branch: Notes CTA href encodes the hub+vault URL,
|
|
7
|
-
* vault name shows in the body,
|
|
8
|
-
*
|
|
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.
|
|
9
10
|
* - Admin (no assigned vault) branch: link to /admin/ visible.
|
|
10
11
|
* - Defensive third branch (non-admin + no vault): "ask the operator"
|
|
11
12
|
* copy renders.
|
|
@@ -13,7 +14,11 @@
|
|
|
13
14
|
* change-password link.
|
|
14
15
|
*/
|
|
15
16
|
import { describe, expect, test } from "bun:test";
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
accountClaudeMcpAddCommand,
|
|
19
|
+
accountMcpEndpoint,
|
|
20
|
+
renderAccountHome,
|
|
21
|
+
} from "../account-home-ui.ts";
|
|
17
22
|
|
|
18
23
|
const HUB_ORIGIN = "https://hub.example";
|
|
19
24
|
const CSRF = "test-csrf-token";
|
|
@@ -27,6 +32,7 @@ describe("renderAccountHome", () => {
|
|
|
27
32
|
hubOrigin: HUB_ORIGIN,
|
|
28
33
|
isFirstAdmin: false,
|
|
29
34
|
csrfToken: CSRF,
|
|
35
|
+
twoFactorEnabled: false,
|
|
30
36
|
});
|
|
31
37
|
// Welcome header includes the username.
|
|
32
38
|
expect(html).toContain("Welcome, alice");
|
|
@@ -38,9 +44,36 @@ describe("renderAccountHome", () => {
|
|
|
38
44
|
expect(html).toContain(`https://notes.parachute.computer/add?url=${encodedVaultUrl}`);
|
|
39
45
|
expect(html).toContain('target="_blank"');
|
|
40
46
|
expect(html).toContain('rel="noopener"');
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
expect(html).toContain(
|
|
47
|
+
// The friend-connect surface: MCP endpoint + `claude mcp add` command,
|
|
48
|
+
// each with a copy button. OAuth path (no token in the command).
|
|
49
|
+
expect(html).toContain(`${HUB_ORIGIN}/vault/alice/mcp`);
|
|
50
|
+
expect(html).toContain(
|
|
51
|
+
`claude mcp add --transport http parachute-alice ${HUB_ORIGIN}/vault/alice/mcp`,
|
|
52
|
+
);
|
|
53
|
+
expect(html).toContain('data-testid="copy-mcp-endpoint"');
|
|
54
|
+
expect(html).toContain('data-testid="copy-mcp-add-command"');
|
|
55
|
+
// The connect command must NOT embed a token — the OAuth path needs none.
|
|
56
|
+
expect(html).not.toContain("--header");
|
|
57
|
+
expect(html).not.toContain("Authorization: Bearer");
|
|
58
|
+
// Copy-button progressive-enhancement script is present.
|
|
59
|
+
expect(html).toContain("navigator.clipboard");
|
|
60
|
+
// Friendlier framing: the block leads with "connect your AI assistant"
|
|
61
|
+
// rather than MCP jargon up top.
|
|
62
|
+
expect(html).toContain('data-testid="connect-ai-heading"');
|
|
63
|
+
expect(html).toContain("Connect your AI");
|
|
64
|
+
// BOTH connect methods render as distinct, labelled blocks.
|
|
65
|
+
expect(html).toContain('data-testid="connect-method-claude-code"');
|
|
66
|
+
expect(html).toContain("Claude Code");
|
|
67
|
+
expect(html).toContain('data-testid="connect-method-claude-ai"');
|
|
68
|
+
expect(html).toContain("Claude.ai");
|
|
69
|
+
// The Claude.ai path mirrors the install.njk canonical phrasing
|
|
70
|
+
// (Settings → Connectors → Add custom connector, paste the endpoint).
|
|
71
|
+
expect(html).toContain("Connectors");
|
|
72
|
+
expect(html).toContain("Add custom connector");
|
|
73
|
+
// A brief "any other MCP client" line is present (no bloat — just one).
|
|
74
|
+
expect(html).toContain('data-testid="connect-any-client-hint"');
|
|
75
|
+
// Notes CTA still present, now framed as the browser-UI option.
|
|
76
|
+
expect(html).toContain('data-testid="open-notes-cta"');
|
|
44
77
|
});
|
|
45
78
|
|
|
46
79
|
test("assigned-vault branch — trailing slash on hubOrigin is normalized", () => {
|
|
@@ -54,12 +87,14 @@ describe("renderAccountHome", () => {
|
|
|
54
87
|
hubOrigin: `${HUB_ORIGIN}/`,
|
|
55
88
|
isFirstAdmin: false,
|
|
56
89
|
csrfToken: CSRF,
|
|
90
|
+
twoFactorEnabled: false,
|
|
57
91
|
});
|
|
58
92
|
const cleanEncoded = encodeURIComponent(`${HUB_ORIGIN}/vault/alice`);
|
|
59
93
|
expect(html).toContain(`https://notes.parachute.computer/add?url=${cleanEncoded}`);
|
|
60
|
-
// The
|
|
61
|
-
|
|
62
|
-
expect(html).
|
|
94
|
+
// The MCP endpoint + connect command also drop the trailing slash — no
|
|
95
|
+
// `//vault` double-slash sneaks in.
|
|
96
|
+
expect(html).toContain(`${HUB_ORIGIN}/vault/alice/mcp`);
|
|
97
|
+
expect(html).not.toContain(`${HUB_ORIGIN}//vault`);
|
|
63
98
|
});
|
|
64
99
|
|
|
65
100
|
test("admin branch — null assignedVault + isFirstAdmin renders an /admin/ link", () => {
|
|
@@ -70,6 +105,7 @@ describe("renderAccountHome", () => {
|
|
|
70
105
|
hubOrigin: HUB_ORIGIN,
|
|
71
106
|
isFirstAdmin: true,
|
|
72
107
|
csrfToken: CSRF,
|
|
108
|
+
twoFactorEnabled: false,
|
|
73
109
|
});
|
|
74
110
|
expect(html).toContain("Welcome, admin");
|
|
75
111
|
expect(html).toContain("hub administrator");
|
|
@@ -90,13 +126,19 @@ describe("renderAccountHome", () => {
|
|
|
90
126
|
hubOrigin: HUB_ORIGIN,
|
|
91
127
|
isFirstAdmin: false,
|
|
92
128
|
csrfToken: CSRF,
|
|
129
|
+
twoFactorEnabled: false,
|
|
93
130
|
});
|
|
94
131
|
expect(html).toContain("Welcome, ghost");
|
|
95
|
-
|
|
132
|
+
// The message explains WHY there's nothing to connect (no vault yet) and
|
|
133
|
+
// gives a clear next step — not just a bare "ask your admin".
|
|
134
|
+
expect(html).toContain("Ask the hub operator to assign you a vault");
|
|
135
|
+
expect(html).toContain("don't have a vault yet");
|
|
96
136
|
// No /admin/ link in this branch — they have no admin role.
|
|
97
137
|
expect(html).not.toContain('href="/admin/"');
|
|
98
138
|
// No Notes CTA.
|
|
99
139
|
expect(html).not.toContain("notes.parachute.computer/add");
|
|
140
|
+
// No connect block — you can't connect a vault you don't have.
|
|
141
|
+
expect(html).not.toContain('data-testid="mcp-connect"');
|
|
100
142
|
});
|
|
101
143
|
|
|
102
144
|
test("account card — change-password link and sign-out form are present", () => {
|
|
@@ -107,6 +149,7 @@ describe("renderAccountHome", () => {
|
|
|
107
149
|
hubOrigin: HUB_ORIGIN,
|
|
108
150
|
isFirstAdmin: false,
|
|
109
151
|
csrfToken: CSRF,
|
|
152
|
+
twoFactorEnabled: false,
|
|
110
153
|
});
|
|
111
154
|
// Change-password link points at the existing /account/change-password
|
|
112
155
|
// route (server-rendered HTML, separate handler).
|
|
@@ -120,6 +163,37 @@ describe("renderAccountHome", () => {
|
|
|
120
163
|
expect(html).toContain("<code>alice</code>");
|
|
121
164
|
});
|
|
122
165
|
|
|
166
|
+
test("account card — 2FA status reflects twoFactorEnabled (hub#473)", () => {
|
|
167
|
+
const off = renderAccountHome({
|
|
168
|
+
username: "alice",
|
|
169
|
+
assignedVaults: ["alice"],
|
|
170
|
+
passwordChanged: true,
|
|
171
|
+
hubOrigin: HUB_ORIGIN,
|
|
172
|
+
isFirstAdmin: false,
|
|
173
|
+
csrfToken: CSRF,
|
|
174
|
+
twoFactorEnabled: false,
|
|
175
|
+
});
|
|
176
|
+
expect(off).toContain('data-testid="2fa-status"');
|
|
177
|
+
expect(off).toContain(">Off<");
|
|
178
|
+
// Off → "Set up two-factor" affordance.
|
|
179
|
+
expect(off).toContain('data-testid="setup-2fa-link"');
|
|
180
|
+
expect(off).toContain('href="/account/2fa"');
|
|
181
|
+
|
|
182
|
+
const on = renderAccountHome({
|
|
183
|
+
username: "alice",
|
|
184
|
+
assignedVaults: ["alice"],
|
|
185
|
+
passwordChanged: true,
|
|
186
|
+
hubOrigin: HUB_ORIGIN,
|
|
187
|
+
isFirstAdmin: false,
|
|
188
|
+
csrfToken: CSRF,
|
|
189
|
+
twoFactorEnabled: true,
|
|
190
|
+
});
|
|
191
|
+
expect(on).toContain(">On<");
|
|
192
|
+
// On → "Manage two-factor" affordance.
|
|
193
|
+
expect(on).toContain('data-testid="manage-2fa-link"');
|
|
194
|
+
expect(on).toContain('href="/account/2fa"');
|
|
195
|
+
});
|
|
196
|
+
|
|
123
197
|
test("multi-vault branch — renders one tile per assigned vault (Phase 2 PR 2)", () => {
|
|
124
198
|
const html = renderAccountHome({
|
|
125
199
|
username: "alice",
|
|
@@ -128,6 +202,7 @@ describe("renderAccountHome", () => {
|
|
|
128
202
|
hubOrigin: HUB_ORIGIN,
|
|
129
203
|
isFirstAdmin: false,
|
|
130
204
|
csrfToken: CSRF,
|
|
205
|
+
twoFactorEnabled: false,
|
|
131
206
|
});
|
|
132
207
|
// Plural heading.
|
|
133
208
|
expect(html).toContain("Your vaults");
|
|
@@ -139,8 +214,28 @@ describe("renderAccountHome", () => {
|
|
|
139
214
|
const familyEncoded = encodeURIComponent(`${HUB_ORIGIN}/vault/family`);
|
|
140
215
|
expect(html).toContain(`https://notes.parachute.computer/add?url=${personalEncoded}`);
|
|
141
216
|
expect(html).toContain(`https://notes.parachute.computer/add?url=${familyEncoded}`);
|
|
142
|
-
//
|
|
143
|
-
expect(html.
|
|
217
|
+
// One per-vault MCP connect block per tile (endpoint + command).
|
|
218
|
+
expect(html).toContain(`${HUB_ORIGIN}/vault/personal/mcp`);
|
|
219
|
+
expect(html).toContain(`${HUB_ORIGIN}/vault/family/mcp`);
|
|
220
|
+
expect(html).toContain(
|
|
221
|
+
`claude mcp add --transport http parachute-personal ${HUB_ORIGIN}/vault/personal/mcp`,
|
|
222
|
+
);
|
|
223
|
+
expect(html).toContain(
|
|
224
|
+
`claude mcp add --transport http parachute-family ${HUB_ORIGIN}/vault/family/mcp`,
|
|
225
|
+
);
|
|
226
|
+
// Two tiles → two copy-endpoint buttons.
|
|
227
|
+
expect(html.split('data-testid="copy-mcp-endpoint"').length - 1).toBe(2);
|
|
228
|
+
// The copy script is emitted once at the section level, not per-tile.
|
|
229
|
+
expect(html.split("<script>").length - 1).toBe(1);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("accountMcpEndpoint / accountClaudeMcpAddCommand build the canonical shapes", () => {
|
|
233
|
+
expect(accountMcpEndpoint("https://hub.example", "work")).toBe(
|
|
234
|
+
"https://hub.example/vault/work/mcp",
|
|
235
|
+
);
|
|
236
|
+
expect(accountClaudeMcpAddCommand("https://hub.example", "work")).toBe(
|
|
237
|
+
"claude mcp add --transport http parachute-work https://hub.example/vault/work/mcp",
|
|
238
|
+
);
|
|
144
239
|
});
|
|
145
240
|
|
|
146
241
|
test("escapes hostile content in username and vault name", () => {
|
|
@@ -149,15 +244,156 @@ describe("renderAccountHome", () => {
|
|
|
149
244
|
// the renderer is a pure function over arbitrary string input and the
|
|
150
245
|
// escape is load-bearing if the validator ever loosens.
|
|
151
246
|
const html = renderAccountHome({
|
|
152
|
-
username: "<script>",
|
|
247
|
+
username: "<script>alert(1)</script>",
|
|
153
248
|
assignedVaults: ["<vault>"],
|
|
154
249
|
passwordChanged: true,
|
|
155
250
|
hubOrigin: HUB_ORIGIN,
|
|
156
251
|
isFirstAdmin: false,
|
|
157
252
|
csrfToken: CSRF,
|
|
253
|
+
twoFactorEnabled: false,
|
|
158
254
|
});
|
|
159
|
-
|
|
160
|
-
|
|
255
|
+
// The injected username/vault metacharacters are escaped — the only
|
|
256
|
+
// `<script>` tag in the output is the page's own copy-button helper, so
|
|
257
|
+
// we assert on the injected payload specifically rather than a blanket
|
|
258
|
+
// "no <script>" (the connect block legitimately emits one).
|
|
259
|
+
expect(html).not.toContain("<script>alert(1)</script>");
|
|
260
|
+
expect(html).toContain("<script>alert(1)</script>");
|
|
161
261
|
expect(html).toContain("<vault>");
|
|
262
|
+
// The escaped vault name also flows into the connect command + endpoint.
|
|
263
|
+
expect(html).toContain("parachute-<vault>");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// --- friend vault-token mint affordance (the new surface) ----------------
|
|
267
|
+
|
|
268
|
+
test("mint affordance — read+write tile offers both verbs, POSTs to the right path", () => {
|
|
269
|
+
const html = renderAccountHome({
|
|
270
|
+
username: "alice",
|
|
271
|
+
assignedVaults: ["work"],
|
|
272
|
+
passwordChanged: true,
|
|
273
|
+
hubOrigin: HUB_ORIGIN,
|
|
274
|
+
isFirstAdmin: false,
|
|
275
|
+
csrfToken: CSRF,
|
|
276
|
+
twoFactorEnabled: false,
|
|
277
|
+
mintableVerbs: { work: ["read", "write"] },
|
|
278
|
+
});
|
|
279
|
+
// The collapsible mint block is present, framed as secondary (headless).
|
|
280
|
+
expect(html).toContain('data-testid="token-mint"');
|
|
281
|
+
expect(html).toContain("Mint an access token");
|
|
282
|
+
expect(html).toContain("for scripts / headless clients");
|
|
283
|
+
// Both verb radios render.
|
|
284
|
+
expect(html).toContain('data-testid="mint-verb-read"');
|
|
285
|
+
expect(html).toContain('data-testid="mint-verb-write"');
|
|
286
|
+
// Form POSTs to the per-vault endpoint with the CSRF token embedded.
|
|
287
|
+
expect(html).toContain('action="/account/vault-token/work"');
|
|
288
|
+
expect(html).toContain('method="POST"');
|
|
289
|
+
expect(html).toContain('data-testid="mint-form"');
|
|
290
|
+
expect(html).toContain(CSRF);
|
|
291
|
+
// Recommends the no-token path as default.
|
|
292
|
+
expect(html).toContain("no-token");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("mint affordance — a read-only role offers ONLY the read verb", () => {
|
|
296
|
+
// Today every assignment is write-role, but the renderer is verb-blind to
|
|
297
|
+
// the role: it shows exactly the verbs it's handed. A read-only cap must
|
|
298
|
+
// never surface a write radio (the server would reject it anyway).
|
|
299
|
+
const html = renderAccountHome({
|
|
300
|
+
username: "alice",
|
|
301
|
+
assignedVaults: ["work"],
|
|
302
|
+
passwordChanged: true,
|
|
303
|
+
hubOrigin: HUB_ORIGIN,
|
|
304
|
+
isFirstAdmin: false,
|
|
305
|
+
csrfToken: CSRF,
|
|
306
|
+
twoFactorEnabled: false,
|
|
307
|
+
mintableVerbs: { work: ["read"] },
|
|
308
|
+
});
|
|
309
|
+
expect(html).toContain('data-testid="mint-verb-read"');
|
|
310
|
+
expect(html).not.toContain('data-testid="mint-verb-write"');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("mint affordance — never offers an admin verb", () => {
|
|
314
|
+
const html = renderAccountHome({
|
|
315
|
+
username: "alice",
|
|
316
|
+
assignedVaults: ["work"],
|
|
317
|
+
passwordChanged: true,
|
|
318
|
+
hubOrigin: HUB_ORIGIN,
|
|
319
|
+
isFirstAdmin: false,
|
|
320
|
+
csrfToken: CSRF,
|
|
321
|
+
twoFactorEnabled: false,
|
|
322
|
+
mintableVerbs: { work: ["read", "write"] },
|
|
323
|
+
});
|
|
324
|
+
expect(html).not.toContain('value="admin"');
|
|
325
|
+
expect(html).not.toContain('data-testid="mint-verb-admin"');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("mint affordance — absent when no mintable verbs (admin / no-vault / unmapped role)", () => {
|
|
329
|
+
// Admin branch: no tiles at all, so no mint block.
|
|
330
|
+
const admin = renderAccountHome({
|
|
331
|
+
username: "admin",
|
|
332
|
+
assignedVaults: [],
|
|
333
|
+
passwordChanged: true,
|
|
334
|
+
hubOrigin: HUB_ORIGIN,
|
|
335
|
+
isFirstAdmin: true,
|
|
336
|
+
csrfToken: CSRF,
|
|
337
|
+
twoFactorEnabled: false,
|
|
338
|
+
});
|
|
339
|
+
expect(admin).not.toContain('data-testid="token-mint"');
|
|
340
|
+
// Assigned vault but empty verb list (fail-closed unknown role) → no block.
|
|
341
|
+
const empty = renderAccountHome({
|
|
342
|
+
username: "alice",
|
|
343
|
+
assignedVaults: ["work"],
|
|
344
|
+
passwordChanged: true,
|
|
345
|
+
hubOrigin: HUB_ORIGIN,
|
|
346
|
+
isFirstAdmin: false,
|
|
347
|
+
csrfToken: CSRF,
|
|
348
|
+
twoFactorEnabled: false,
|
|
349
|
+
mintableVerbs: { work: [] },
|
|
350
|
+
});
|
|
351
|
+
expect(empty).not.toContain('data-testid="token-mint"');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("minted-token banner — shows the token once with a save-it warning, no revoke claim", () => {
|
|
355
|
+
const html = renderAccountHome({
|
|
356
|
+
username: "alice",
|
|
357
|
+
assignedVaults: ["work"],
|
|
358
|
+
passwordChanged: true,
|
|
359
|
+
hubOrigin: HUB_ORIGIN,
|
|
360
|
+
isFirstAdmin: false,
|
|
361
|
+
csrfToken: CSRF,
|
|
362
|
+
twoFactorEnabled: false,
|
|
363
|
+
mintableVerbs: { work: ["read", "write"] },
|
|
364
|
+
mintedToken: {
|
|
365
|
+
vaultName: "work",
|
|
366
|
+
verb: "read",
|
|
367
|
+
token: "eyJhbGciOi.FAKE.TOKEN",
|
|
368
|
+
expiresInDays: 90,
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
expect(html).toContain('data-testid="minted-token-banner"');
|
|
372
|
+
expect(html).toContain("eyJhbGciOi.FAKE.TOKEN");
|
|
373
|
+
expect(html).toContain('data-testid="copy-minted-token"');
|
|
374
|
+
// Explicit "won't be shown again" + the scope + the TTL.
|
|
375
|
+
expect(html).toContain("won't be shown again");
|
|
376
|
+
expect(html).toContain("vault:work:read");
|
|
377
|
+
expect(html).toContain("90 days");
|
|
378
|
+
// No false revoke-yourself promise (no friend-facing revoke today).
|
|
379
|
+
expect(html).toContain("ask the hub operator");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("mint error banner — surfaces an inline authorization error", () => {
|
|
383
|
+
const html = renderAccountHome({
|
|
384
|
+
username: "alice",
|
|
385
|
+
assignedVaults: ["work"],
|
|
386
|
+
passwordChanged: true,
|
|
387
|
+
hubOrigin: HUB_ORIGIN,
|
|
388
|
+
isFirstAdmin: false,
|
|
389
|
+
csrfToken: CSRF,
|
|
390
|
+
twoFactorEnabled: false,
|
|
391
|
+
mintableVerbs: { work: ["read", "write"] },
|
|
392
|
+
mintError: 'You\'re not assigned to a vault named "other".',
|
|
393
|
+
});
|
|
394
|
+
expect(html).toContain('data-testid="mint-error-banner"');
|
|
395
|
+
expect(html).toContain("not assigned");
|
|
396
|
+
// Error render must NOT also show a token.
|
|
397
|
+
expect(html).not.toContain('data-testid="minted-token-banner"');
|
|
162
398
|
});
|
|
163
399
|
});
|