@openparachute/hub 0.6.1-rc.3 → 0.6.1
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 +2 -2
- package/src/__tests__/account-home-ui.test.ts +6 -4
- package/src/__tests__/account-vault-token.test.ts +8 -5
- package/src/__tests__/cloudflare-config.test.ts +65 -1
- package/src/__tests__/expose-cloudflare.test.ts +412 -16
- package/src/__tests__/oauth-handlers.test.ts +64 -55
- package/src/__tests__/users.test.ts +9 -5
- package/src/account-home-ui.ts +6 -1
- package/src/account-vault-token.ts +15 -14
- package/src/cli.ts +2 -1
- package/src/cloudflare/config.ts +70 -4
- package/src/commands/expose-cloudflare.ts +171 -39
- package/src/help.ts +7 -2
- package/src/oauth-handlers.ts +8 -6
- package/src/scope-explanations.ts +7 -4
- package/src/users.ts +22 -15
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/hub",
|
|
3
|
-
"version": "0.6.1
|
|
4
|
-
"description": "parachute
|
|
3
|
+
"version": "0.6.1",
|
|
4
|
+
"description": "parachute — 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"
|
|
@@ -310,7 +310,9 @@ describe("renderAccountHome", () => {
|
|
|
310
310
|
expect(html).not.toContain('data-testid="mint-verb-write"');
|
|
311
311
|
});
|
|
312
312
|
|
|
313
|
-
test("mint affordance —
|
|
313
|
+
test("mint affordance — offers the admin verb when the user holds it", () => {
|
|
314
|
+
// 2026-05-30: assigned users hold read/write/admin on their vault, so the
|
|
315
|
+
// mint form offers admin (the live `vaultVerbsForUserVault` returns it).
|
|
314
316
|
const html = renderAccountHome({
|
|
315
317
|
username: "alice",
|
|
316
318
|
assignedVaults: ["work"],
|
|
@@ -319,10 +321,10 @@ describe("renderAccountHome", () => {
|
|
|
319
321
|
isFirstAdmin: false,
|
|
320
322
|
csrfToken: CSRF,
|
|
321
323
|
twoFactorEnabled: false,
|
|
322
|
-
mintableVerbs: { work: ["read", "write"] },
|
|
324
|
+
mintableVerbs: { work: ["read", "write", "admin"] },
|
|
323
325
|
});
|
|
324
|
-
expect(html).
|
|
325
|
-
expect(html).
|
|
326
|
+
expect(html).toContain('value="admin"');
|
|
327
|
+
expect(html).toContain('data-testid="mint-verb-admin"');
|
|
326
328
|
});
|
|
327
329
|
|
|
328
330
|
test("mint affordance — absent when no mintable verbs (admin / no-vault / unmapped role)", () => {
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* - UNassigned vault → 403 (cannot mint for a vault not in the
|
|
11
11
|
* user's `user_vaults` assignment — blocks
|
|
12
12
|
* cross-vault).
|
|
13
|
-
* - `admin` verb →
|
|
13
|
+
* - `admin` verb → minted for an ASSIGNED vault (2026-05-30:
|
|
14
|
+
* assigned users hold full vault authority).
|
|
14
15
|
* - Broader/garbage verb → rejected.
|
|
15
16
|
* - First admin → 403 (no `user_vaults` rows → unrestricted
|
|
16
17
|
* admins use the SPA path, not this one).
|
|
@@ -245,17 +246,19 @@ describe("handleAccountVaultTokenPost — authorization gates (adversarial)", ()
|
|
|
245
246
|
expect(res.status).toBe(403);
|
|
246
247
|
});
|
|
247
248
|
|
|
248
|
-
test("admin verb
|
|
249
|
+
test("200 mints vault:<name>:admin when verb=admin (assigned users hold admin, 2026-05-30)", async () => {
|
|
249
250
|
const { cookie, csrfToken } = await seedFriend(["work"]);
|
|
250
251
|
const res = await handleAccountVaultTokenPost(
|
|
251
252
|
mintReq("work", { cookie, csrfToken, verb: "admin" }),
|
|
252
253
|
"work",
|
|
253
254
|
deps(),
|
|
254
255
|
);
|
|
255
|
-
expect(res.status).toBe(
|
|
256
|
+
expect(res.status).toBe(200);
|
|
256
257
|
const html = await res.text();
|
|
257
|
-
|
|
258
|
-
|
|
258
|
+
const token = html.match(/data-testid="minted-token-value">([^<]+)</)?.[1] as string;
|
|
259
|
+
const validated = await validateAccessToken(harness.db, token, ISSUER);
|
|
260
|
+
const scopeClaim = (validated.payload as { scope?: string }).scope ?? "";
|
|
261
|
+
expect(scopeClaim.split(/\s+/)).toEqual(["vault:work:admin"]);
|
|
259
262
|
});
|
|
260
263
|
|
|
261
264
|
test("a garbage / broader verb is rejected", async () => {
|
|
@@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { renderConfig, writeConfig } from "../cloudflare/config.ts";
|
|
5
|
+
import { deriveTunnelName, renderConfig, writeConfig } from "../cloudflare/config.ts";
|
|
6
|
+
import { isValidTunnelName } from "../commands/expose-cloudflare.ts";
|
|
6
7
|
|
|
7
8
|
describe("cloudflare config", () => {
|
|
8
9
|
test("renderConfig produces a valid cloudflared YAML with one-hostname ingress + catch-all 404", () => {
|
|
@@ -52,3 +53,66 @@ describe("cloudflare config", () => {
|
|
|
52
53
|
}
|
|
53
54
|
});
|
|
54
55
|
});
|
|
56
|
+
|
|
57
|
+
describe("deriveTunnelName (#491 — per-hostname dedicated tunnels)", () => {
|
|
58
|
+
test("prefixes parachute- and turns dots into hyphens", () => {
|
|
59
|
+
expect(deriveTunnelName("our.parachute.computer")).toBe("parachute-our-parachute-computer");
|
|
60
|
+
expect(deriveTunnelName("vault.example.com")).toBe("parachute-vault-example-com");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("lowercases and strips characters outside [a-z0-9_-]", () => {
|
|
64
|
+
// Uppercase → lowercase; a stray char that an over-permissive hostname
|
|
65
|
+
// validator might let through is dropped so the result stays a valid
|
|
66
|
+
// tunnel name. (Dots are already mapped to hyphens before stripping.)
|
|
67
|
+
expect(deriveTunnelName("Vault.Example.COM")).toBe("parachute-vault-example-com");
|
|
68
|
+
expect(deriveTunnelName("a_b-c.example.com")).toBe("parachute-a_b-c-example-com");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("every derived name satisfies isValidTunnelName", () => {
|
|
72
|
+
for (const host of [
|
|
73
|
+
"our.parachute.computer",
|
|
74
|
+
"vault.example.com",
|
|
75
|
+
"Vault.Example.COM",
|
|
76
|
+
"a_b-c.example.com",
|
|
77
|
+
`${"x".repeat(200)}.example.com`,
|
|
78
|
+
]) {
|
|
79
|
+
const name = deriveTunnelName(host);
|
|
80
|
+
expect(isValidTunnelName(name)).toBe(true);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("truncates + appends a stable 8-hex suffix when the name would exceed 64 chars", () => {
|
|
85
|
+
const longHost = `${"sub.".repeat(20)}example.com`; // way over 64 once prefixed
|
|
86
|
+
const name = deriveTunnelName(longHost);
|
|
87
|
+
expect(name.length).toBeLessThanOrEqual(64);
|
|
88
|
+
expect(name.startsWith("parachute-")).toBe(true);
|
|
89
|
+
// 8-hex stable suffix on the end.
|
|
90
|
+
expect(name).toMatch(/-[0-9a-f]{8}$/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("is deterministic — same hostname always derives the same name (idempotent re-expose)", () => {
|
|
94
|
+
const longHost = `${"sub.".repeat(20)}example.com`;
|
|
95
|
+
expect(deriveTunnelName(longHost)).toBe(deriveTunnelName(longHost));
|
|
96
|
+
expect(deriveTunnelName("our.parachute.computer")).toBe(
|
|
97
|
+
deriveTunnelName("our.parachute.computer"),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("two distinct long hostnames whose truncated bodies are identical don't collide", () => {
|
|
102
|
+
// Identical leading labels long enough that the body truncation
|
|
103
|
+
// (parachute- + body-slice + -<8hex>, capped at 64) cuts BEFORE the
|
|
104
|
+
// differing tail — so the truncated bodies are byte-identical and only the
|
|
105
|
+
// full-hostname hash distinguishes them. Verifies the suffix disambiguates.
|
|
106
|
+
const sharedPrefix = "x".repeat(80); // single long label, well past the truncation point
|
|
107
|
+
const a = `${sharedPrefix}.alpha.example.com`;
|
|
108
|
+
const b = `${sharedPrefix}.beta.example.com`;
|
|
109
|
+
const nameA = deriveTunnelName(a);
|
|
110
|
+
const nameB = deriveTunnelName(b);
|
|
111
|
+
expect(nameA.length).toBeLessThanOrEqual(64);
|
|
112
|
+
expect(nameB.length).toBeLessThanOrEqual(64);
|
|
113
|
+
// Bodies before the suffix are identical (truncation cut inside the shared
|
|
114
|
+
// prefix), so the names can only differ in the trailing 8-hex hash.
|
|
115
|
+
expect(nameA.slice(0, -8)).toBe(nameB.slice(0, -8));
|
|
116
|
+
expect(nameA).not.toBe(nameB);
|
|
117
|
+
});
|
|
118
|
+
});
|