@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.6.1-rc.3",
4
- "description": "parachute \u2014 the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
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 — never offers an admin verb", () => {
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).not.toContain('value="admin"');
325
- expect(html).not.toContain('data-testid="mint-verb-admin"');
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 → rejected (not in the form vocabulary).
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 is rejected never mints vault:<name>:admin", async () => {
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(400);
256
+ expect(res.status).toBe(200);
256
257
  const html = await res.text();
257
- expect(html).not.toContain('data-testid="minted-token-banner"');
258
- expect(html).not.toContain("vault:work:admin");
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
+ });