@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.
@@ -8542,12 +8542,12 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8542
8542
  // Test 9 — privesc: read/write assigned (non-owner) user requests
8543
8543
  // vault:work:admin + vault:work:write → admin DROPPED, token has write only,
8544
8544
  // recorded grant lacks admin.
8545
- test("[9] non-owner read/write user requests admin+write → admin DROPPED (write only), grant lacks admin", async () => {
8545
+ test("[9] non-owner ASSIGNED to the vault requests admin+write → BOTH granted (assigned users hold admin)", async () => {
8546
8546
  const { db, cleanup } = await makeDb();
8547
8547
  try {
8548
8548
  await createUser(db, "owner", "pw"); // first user = owner; consumes the admin slot.
8549
8549
  const friend = await createUser(db, "friend", "pw", { allowMulti: true });
8550
- setUserVaults(db, friend.id, ["work"]); // role=write → verbs [read, write]
8550
+ setUserVaults(db, friend.id, ["work"]); // role=write → verbs [read, write, admin]
8551
8551
  const session = createSession(db, { userId: friend.id });
8552
8552
  const reg = registerClient(db, {
8553
8553
  redirectUris: ["https://app.example/cb"],
@@ -8564,20 +8564,20 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8564
8564
  expect(consentRes.status).toBe(302);
8565
8565
  const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
8566
8566
  const { scope, aud } = await redeemToScopeAud(db, code ?? "", reg.client.clientId, verifier);
8567
- // admin dropped; write kept.
8568
- expect(scope.split(" ").sort()).toEqual(["vault:work:write"]);
8567
+ // Assigned user holds admin on their own vault → BOTH kept (2026-05-30 policy).
8568
+ expect(scope.split(" ").sort()).toEqual(["vault:work:admin", "vault:work:write"]);
8569
8569
  expect(aud).toBe("vault.work");
8570
- // Recorded grant lacks admin.
8570
+ // Recorded grant includes admin.
8571
8571
  const grant = findGrant(db, friend.id, reg.client.clientId);
8572
8572
  expect(grant?.scopes).toContain("vault:work:write");
8573
- expect(grant?.scopes).not.toContain("vault:work:admin");
8573
+ expect(grant?.scopes).toContain("vault:work:admin");
8574
8574
  } finally {
8575
8575
  cleanup();
8576
8576
  }
8577
8577
  });
8578
8578
 
8579
8579
  // Test 10 — non-owner admin-ONLY request → REFUSED (clear error), no token.
8580
- test("[10] non-owner admin-only request → REFUSED with invalid_scope, no token minted", async () => {
8580
+ test("[10] non-owner assigned, admin-only request → GRANTED (holds admin on their vault)", async () => {
8581
8581
  const { db, cleanup } = await makeDb();
8582
8582
  try {
8583
8583
  await createUser(db, "owner", "pw");
@@ -8588,7 +8588,7 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8588
8588
  redirectUris: ["https://app.example/cb"],
8589
8589
  status: "approved",
8590
8590
  });
8591
- const { challenge } = makePkce();
8591
+ const { verifier, challenge } = makePkce();
8592
8592
  const consentRes = await submitConsent(
8593
8593
  db,
8594
8594
  session.id,
@@ -8596,14 +8596,17 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8596
8596
  "vault:work:admin",
8597
8597
  challenge,
8598
8598
  );
8599
- // Capping leaves an EMPTY scope setrefuse (no zero-scope token).
8599
+ // Assigned user holds admin on their vault minted, not refused.
8600
8600
  expect(consentRes.status).toBe(302);
8601
8601
  const loc = new URL(consentRes.headers.get("location") ?? "");
8602
8602
  expect(loc.origin + loc.pathname).toBe("https://app.example/cb");
8603
- expect(loc.searchParams.get("error")).toBe("invalid_scope");
8604
- expect(loc.searchParams.get("code")).toBeNull();
8605
- // No grant recorded.
8606
- expect(findGrant(db, friend.id, reg.client.clientId)).toBeNull();
8603
+ const code = loc.searchParams.get("code");
8604
+ expect(code).toBeTruthy();
8605
+ const { scope, aud } = await redeemToScopeAud(db, code ?? "", reg.client.clientId, verifier);
8606
+ expect(scope.split(" ")).toEqual(["vault:work:admin"]);
8607
+ expect(aud).toBe("vault.work");
8608
+ const grant = findGrant(db, friend.id, reg.client.clientId);
8609
+ expect(grant?.scopes).toContain("vault:work:admin");
8607
8610
  } finally {
8608
8611
  cleanup();
8609
8612
  }
@@ -8611,7 +8614,7 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8611
8614
 
8612
8615
  // Test 11 — non-owner unnamed vault:admin + picks assigned vault → after
8613
8616
  // narrowing, admin dropped (cap runs post-narrow).
8614
- test("[11] non-owner unnamed vault:admin + picks assigned vault → admin dropped post-narrow", async () => {
8617
+ test("[11] non-owner unnamed vault:admin + picks assigned vault → admin KEPT post-narrow", async () => {
8615
8618
  const { db, cleanup } = await makeDb();
8616
8619
  try {
8617
8620
  await createUser(db, "owner", "pw");
@@ -8632,16 +8635,16 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8632
8635
  challenge,
8633
8636
  { vault_pick: "work" },
8634
8637
  );
8635
- // narrowVaultScopes → vault:work:admin + vault:work:write; cap drops
8636
- // admin (non-owner doesn't hold it), keeps write → mints write only.
8638
+ // narrowVaultScopes → vault:work:admin + vault:work:write; cap KEEPS
8639
+ // both (assigned user holds admin on their picked vault) → mints both.
8637
8640
  expect(consentRes.status).toBe(302);
8638
8641
  const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
8639
8642
  expect(code).toBeTruthy();
8640
8643
  const { scope } = await redeemToScopeAud(db, code ?? "", reg.client.clientId, verifier);
8641
- expect(scope.split(" ").sort()).toEqual(["vault:work:write"]);
8644
+ expect(scope.split(" ").sort()).toEqual(["vault:work:admin", "vault:work:write"]);
8642
8645
  const grant = findGrant(db, friend.id, reg.client.clientId);
8643
8646
  expect(grant?.scopes).toContain("vault:work:write");
8644
- expect(grant?.scopes).not.toContain("vault:work:admin");
8647
+ expect(grant?.scopes).toContain("vault:work:admin");
8645
8648
  } finally {
8646
8649
  cleanup();
8647
8650
  }
@@ -8770,35 +8773,41 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8770
8773
  // issueAuthCodeRedirect with an admin scope via ANY path (skip-consent /
8771
8774
  // same-hub / consent). Assert no grants row ever contains an un-held admin
8772
8775
  // verb across each path.
8773
- test("[15] bypass-proof: no mint path ever records an un-held admin verb for a non-owner", async () => {
8776
+ test("[15] bypass-proof: no mint path grants admin on a vault the user is NOT assigned", async () => {
8774
8777
  const { db, cleanup } = await makeDb();
8775
8778
  try {
8776
8779
  await createUser(db, "owner", "pw");
8777
8780
  const friend = await createUser(db, "friend", "pw", { allowMulti: true });
8778
- setUserVaults(db, friend.id, ["work"]); // verbs [read, write] only
8781
+ // Assigned to "work" only holds work:read/write/admin, but NOT "other"
8782
+ // (which exists in CAP_MANIFEST). "other" is the un-held boundary.
8783
+ setUserVaults(db, friend.id, ["work"]);
8779
8784
  const session = createSession(db, { userId: friend.id });
8780
8785
 
8781
- // Path A — consent-submit with admin+write admin dropped, recorded grant
8782
- // lacks admin.
8786
+ // Path A — consent-submit admin on the UNASSIGNED "other" → the cap
8787
+ // empties the set (friend doesn't hold it) → invalid_scope refusal, no
8788
+ // grant. (Granting admin on the user's OWN assigned vault is exercised by
8789
+ // [9]/[10]; here we isolate the un-held boundary.)
8783
8790
  const regConsent = registerClient(db, {
8784
8791
  redirectUris: ["https://app.example/cb"],
8785
8792
  status: "approved",
8786
8793
  });
8787
8794
  {
8788
8795
  const { challenge } = makePkce();
8789
- await submitConsent(
8796
+ const res = await submitConsent(
8790
8797
  db,
8791
8798
  session.id,
8792
8799
  regConsent.client.clientId,
8793
- "vault:work:admin vault:work:write",
8800
+ "vault:other:admin",
8794
8801
  challenge,
8795
8802
  );
8796
- const g = findGrant(db, friend.id, regConsent.client.clientId);
8797
- expect(g?.scopes ?? []).not.toContain("vault:work:admin");
8803
+ // The consent-submit assignment gate rejects a request naming a vault
8804
+ // the user isn't assigned (400) refused before any mint, no grant.
8805
+ expect(res.status).toBe(400);
8806
+ expect(findGrant(db, friend.id, regConsent.client.clientId)).toBeNull();
8798
8807
  }
8799
8808
 
8800
- // Path B — same-hub client requesting admin consent renders (admin gate
8801
- // blocks the silent path); no grant recorded with admin.
8809
+ // Path B — same-hub client requesting admin on the UNASSIGNED "other"
8810
+ // consent renders (admin gate blocks the silent path); no grant with it.
8802
8811
  const regSameHub = registerClient(db, {
8803
8812
  redirectUris: ["https://app.example/cb"],
8804
8813
  status: "approved",
@@ -8815,7 +8824,7 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8815
8824
  response_type: "code",
8816
8825
  code_challenge: challenge,
8817
8826
  code_challenge_method: "S256",
8818
- scope: "vault:work:admin",
8827
+ scope: "vault:other:admin",
8819
8828
  }),
8820
8829
  { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
8821
8830
  ),
@@ -8823,22 +8832,23 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8823
8832
  );
8824
8833
  expect(res.status).toBe(200); // consent, not silent-mint
8825
8834
  const g = findGrant(db, friend.id, regSameHub.client.clientId);
8826
- expect(g?.scopes ?? []).not.toContain("vault:work:admin");
8835
+ expect(g?.scopes ?? []).not.toContain("vault:other:admin");
8827
8836
  }
8828
8837
 
8829
- // Path C — skip-consent: even if a grant row were somehow seeded with an
8830
- // admin verb, the cap at issueAuthCodeRedirect drops it before minting AND
8831
- // re-records the capped set. Seed a poisoned grant, drive skip-consent,
8832
- // and assert the minted token + the (re-recorded) grant carry no admin.
8838
+ // Path C — skip-consent: a grant row seeded with an UNASSIGNED-vault admin
8839
+ // verb. The cap at issueAuthCodeRedirect is the authority, not the grant
8840
+ // row request only the held write scope (skip-consent fires) and the
8841
+ // minted token carries no other:admin (the un-held verb never rides along).
8833
8842
  const regSkip = registerClient(db, {
8834
8843
  redirectUris: ["https://app.example/cb"],
8835
8844
  status: "approved",
8836
8845
  });
8837
- recordGrant(db, friend.id, regSkip.client.clientId, ["vault:work:write", "vault:work:admin"]);
8846
+ recordGrant(db, friend.id, regSkip.client.clientId, [
8847
+ "vault:work:write",
8848
+ "vault:other:admin",
8849
+ ]);
8838
8850
  {
8839
8851
  const { verifier, challenge } = makePkce();
8840
- // Request only the held write scope so skip-consent fires (covered by
8841
- // the seeded grant) and routes through issueAuthCodeRedirect.
8842
8852
  const res = handleAuthorizeGet(
8843
8853
  db,
8844
8854
  new Request(
@@ -8857,37 +8867,36 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8857
8867
  expect(res.status).toBe(302); // skip-consent silent mint of the held scope
8858
8868
  const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
8859
8869
  const { scope } = await redeemToScopeAud(db, code ?? "", regSkip.client.clientId, verifier);
8860
- expect(scope.split(" ")).not.toContain("vault:work:admin");
8870
+ expect(scope.split(" ")).not.toContain("vault:other:admin");
8861
8871
  }
8862
8872
  } finally {
8863
8873
  cleanup();
8864
8874
  }
8865
8875
  });
8866
8876
 
8867
- // Reviewer fold (security-relevant): test 15 path C only requested `write`
8868
- // against the poisoned client, which proves the cap doesn't *re-record* an
8869
- // un-held verb. This case requests `vault:work:admin` DIRECTLY against a
8870
- // client whose grant row ALREADY lists `vault:work:admin` (poisoned). The
8871
- // skip-consent gate fires (the requested admin scope IS covered by the
8872
- // poisoned grant) and routes through issueAuthCodeRedirect — where the CAP,
8873
- // not the grant lookup, drops the admin verb. Admin-only request → caps to
8874
- // EMPTY → invalid_scope refusal, no code, no token. This pins that the
8875
- // cap-before-issueAuthCode invariant holds even when a stale admin grant
8876
- // would otherwise satisfy the coverage check.
8877
- test("[15b] non-owner requests admin DIRECTLY against a POISONED-grant client → cap refuses, no admin token", async () => {
8877
+ // Reviewer fold (security-relevant): test 15 path C only requested the held
8878
+ // `write` scope, proving the cap doesn't re-record an un-held verb. This case
8879
+ // requests admin on the UNASSIGNED "other" vault DIRECTLY against a client
8880
+ // whose grant row already lists `vault:other:admin` (poisoned). The
8881
+ // skip-consent gate fires (the requested scope IS covered by the poisoned
8882
+ // grant) and routes through issueAuthCodeRedirect — where the CAP, not the
8883
+ // grant lookup, drops the un-held verb. Admin-only request → caps to EMPTY →
8884
+ // invalid_scope refusal, no code, no token. Pins that the cap-before-
8885
+ // issueAuthCode invariant holds even when a stale grant satisfies coverage.
8886
+ test("[15b] non-owner requests admin on an UNASSIGNED vault against a POISONED-grant client → cap refuses", async () => {
8878
8887
  const { db, cleanup } = await makeDb();
8879
8888
  try {
8880
8889
  await createUser(db, "owner", "pw");
8881
8890
  const friend = await createUser(db, "friend", "pw", { allowMulti: true });
8882
- setUserVaults(db, friend.id, ["work"]); // verbs [read, write] onlynever admin
8891
+ setUserVaults(db, friend.id, ["work"]); // holds work:* (incl. admin)NOT "other"
8883
8892
  const session = createSession(db, { userId: friend.id });
8884
8893
  const reg = registerClient(db, {
8885
8894
  redirectUris: ["https://app.example/cb"],
8886
8895
  status: "approved",
8887
8896
  });
8888
- // Poisoned grant: already lists vault:work:admin (so the skip-consent
8889
- // coverage check would pass for a direct admin request).
8890
- recordGrant(db, friend.id, reg.client.clientId, ["vault:work:write", "vault:work:admin"]);
8897
+ // Poisoned grant: already lists vault:other:admin (so the skip-consent
8898
+ // coverage check would pass for a direct other:admin request).
8899
+ recordGrant(db, friend.id, reg.client.clientId, ["vault:work:write", "vault:other:admin"]);
8891
8900
 
8892
8901
  const { challenge } = makePkce();
8893
8902
  const res = handleAuthorizeGet(
@@ -8899,14 +8908,14 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8899
8908
  response_type: "code",
8900
8909
  code_challenge: challenge,
8901
8910
  code_challenge_method: "S256",
8902
- scope: "vault:work:admin",
8911
+ scope: "vault:other:admin",
8903
8912
  state: "poisoned-direct",
8904
8913
  }),
8905
8914
  { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
8906
8915
  ),
8907
8916
  capDeps,
8908
8917
  );
8909
- // The cap leaves an EMPTY set (non-owner doesn't hold admin) → refuse.
8918
+ // The cap leaves an EMPTY set (friend isn't assigned "other") → refuse.
8910
8919
  // 302 to redirect_uri with invalid_scope and NO code — no token minted.
8911
8920
  expect(res.status).toBe(302);
8912
8921
  const loc = new URL(res.headers.get("location") ?? "");
@@ -740,10 +740,13 @@ describe("getFirstAdminId / isFirstAdmin", () => {
740
740
 
741
741
  describe("vaultVerbsForRole / vaultVerbsForUserVault (friend token-mint cap)", () => {
742
742
  test("vaultVerbsForRole maps roles to verbs, fails closed on unknown", () => {
743
- expect(vaultVerbsForRole("write")).toEqual(["read", "write"]);
743
+ // Assigned users (role=write, today's default) hold FULL vault authority
744
+ // incl. admin (2026-05-30 policy: any assigned user gets admin). A
745
+ // deliberate read-only assignment stays read-only. Unknown role strings
746
+ // (including the literal "admin") map to [] — only the recognised roles
747
+ // grant verbs; never silently default to write.
748
+ expect(vaultVerbsForRole("write")).toEqual(["read", "write", "admin"]);
744
749
  expect(vaultVerbsForRole("read")).toEqual(["read"]);
745
- // Unknown / future roles grant NO mintable verb — never silently
746
- // default to write. `admin` is explicitly NOT a mintable role here.
747
750
  expect(vaultVerbsForRole("admin")).toEqual([]);
748
751
  expect(vaultVerbsForRole("owner")).toEqual([]);
749
752
  expect(vaultVerbsForRole("")).toEqual([]);
@@ -757,8 +760,9 @@ describe("vaultVerbsForRole / vaultVerbsForUserVault (friend token-mint cap)", (
757
760
  allowMulti: true,
758
761
  assignedVaults: ["work"],
759
762
  });
760
- // createUser/setUserVaults insert role='write' today → read+write.
761
- expect(vaultVerbsForUserVault(db, friend.id, "work")).toEqual(["read", "write"]);
763
+ // createUser/setUserVaults insert role='write' today → read+write+admin
764
+ // (assigned users hold full vault authority, 2026-05-30 policy).
765
+ expect(vaultVerbsForUserVault(db, friend.id, "work")).toEqual(["read", "write", "admin"]);
762
766
  } finally {
763
767
  cleanup();
764
768
  }
@@ -384,7 +384,12 @@ function renderTokenMintBlock(
384
384
  const radios = verbs
385
385
  .map((verb, i) => {
386
386
  const checked = i === 0 ? " checked" : "";
387
- const label = verb === "read" ? "Read-only" : "Read + write";
387
+ const label =
388
+ verb === "read"
389
+ ? "Read-only"
390
+ : verb === "admin"
391
+ ? "Full (read, write, rotate tokens + config)"
392
+ : "Read + write";
388
393
  return `
389
394
  <label class="mint-verb-option">
390
395
  <input type="radio" name="verb" value="${verb}"${checked}
@@ -21,12 +21,10 @@
21
21
  * `vaultVerbsForUserVault` (which returns `null` for an unassigned
22
22
  * vault), NOT from the verb-blind `assignedVaults` array.
23
23
  * 3. **Scope cap.** The requested verb MUST be one the user's assignment
24
- * role permits, and may only ever be `read` or `write` NEVER `admin`,
25
- * never a broader scope than the user holds. `vaultVerbsForUserVault`
26
- * returns the role's verb set (today always `["read", "write"]` since
27
- * every assignment is `role = 'write'`); a verb outside that set → 403.
28
- * `admin` is not in the form's vocabulary at all and is rejected at the
29
- * parse step as an invalid verb.
24
+ * role permits. As of 2026-05-30 an assigned user holds the full set
25
+ * `["read", "write", "admin"]` on their vault (`vaultVerbsForUserVault`),
26
+ * so this surface mints admin tokens too consistent with the OAuth
27
+ * flow. A verb outside the held set, or for an unassigned vault, → 403.
30
28
  *
31
29
  * The first admin (unrestricted, empty `assignedVaults`) has no `user_vaults`
32
30
  * rows, so gate 2 returns `null` for every vault and the admin gets a 403
@@ -81,7 +79,7 @@ import { type VaultVerb, getUserById, isFirstAdmin, vaultVerbsForUserVault } fro
81
79
  /** Matches the manifest vault-name validator + `/admin/vault-admin-token`. */
82
80
  const VAULT_NAME_RE = /^[a-zA-Z0-9_-]+$/;
83
81
  /** Verbs this surface will ever mint. `admin` is deliberately absent. */
84
- const ALLOWED_VERBS: readonly VaultVerb[] = ["read", "write"];
82
+ const ALLOWED_VERBS: readonly VaultVerb[] = ["read", "write", "admin"];
85
83
  /** client_id stamped on the minted JWT + registry row. */
86
84
  const ACCOUNT_VAULT_TOKEN_CLIENT_ID = "parachute-account";
87
85
 
@@ -199,14 +197,15 @@ export async function handleAccountVaultTokenPost(
199
197
  return renderHome(400, { mintError: `"${vaultName}" is not a valid vault name.` });
200
198
  }
201
199
 
202
- // Verb parse — must be exactly one of read/write. `admin` (or anything
203
- // else) is not in this surface's vocabulary and is rejected here, well
204
- // before authority is even consulted.
200
+ // Verb parse — must be one of read/write/admin (this surface's vocabulary).
201
+ // Anything else is rejected here, well before authority is even consulted;
202
+ // the per-vault authority cap (gate 3 below) then drops verbs the user
203
+ // doesn't actually hold.
205
204
  const verbRaw = form.get("verb");
206
205
  const verb = typeof verbRaw === "string" ? verbRaw : "";
207
206
  if (!ALLOWED_VERBS.includes(verb as VaultVerb)) {
208
207
  return renderHome(400, {
209
- mintError: "Pick an access level (read or write).",
208
+ mintError: "Pick an access level (read, write, or admin).",
210
209
  });
211
210
  }
212
211
  const requestedVerb = verb as VaultVerb;
@@ -217,9 +216,11 @@ export async function handleAccountVaultTokenPost(
217
216
  // (fail-closed for an unknown role): 403.
218
217
  // - [...] → the verbs the assignment role permits. The requested verb
219
218
  // must be in this set (gate 3): else 403.
220
- // This is the cap to the user's actual authority — it blocks minting for
221
- // an unassigned vault, a broader verb than the role grants, and (since the
222
- // set never contains `admin`) any admin escalation.
219
+ // This is the cap to the user's actual authority — it blocks minting for an
220
+ // unassigned vault or a verb the role doesn't grant. Assigned users hold
221
+ // read/write/admin on their vault (2026-05-30), so admin mints for an
222
+ // assigned vault; the cap still refuses admin (and everything else) for a
223
+ // vault the user isn't assigned (`allowedForUser === null`).
223
224
  const allowedForUser = vaultVerbsForUserVault(deps.db, user.id, vaultName);
224
225
  if (allowedForUser === null) {
225
226
  return renderHome(403, {
package/src/cli.ts CHANGED
@@ -159,7 +159,8 @@ function extractNamedFlag(
159
159
  * fall through to today's Tailscale
160
160
  * default (CI escape hatch, #29)
161
161
  * --domain=<host> hostname for the Cloudflare path
162
- * --tunnel-name=<name> named tunnel override (#32)
162
+ * --tunnel-name=<name> named tunnel override (#32); defaults to a
163
+ * per-hostname dedicated name (#491)
163
164
  *
164
165
  * Returns the stripped argv so the layer/action parser sees `[layer, action?]`
165
166
  * regardless of flag placement. `--tailnet` + `--cloudflare` together is
@@ -2,18 +2,84 @@ import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { CONFIG_DIR } from "../config.ts";
4
4
 
5
+ /**
6
+ * The legacy shared tunnel name. Pre-#491, every machine defaulted its
7
+ * Cloudflare tunnel to this single constant — but Cloudflare tunnels are
8
+ * account-wide, so a second machine exposing a *different* hostname found and
9
+ * reused the SAME tunnel, both connectors registered on one UUID, and the edge
10
+ * load-balanced requests across them → a request for host B could land on host
11
+ * A's connector (whose config.yml only routes host A) → ~50% cross-host 404s.
12
+ *
13
+ * The default is now a per-hostname derived name (`deriveTunnelName`). This
14
+ * constant's role narrows to "the legacy shared name we migrate away from":
15
+ * - the up-path legacy-sweep kills a stale `"parachute"` connector on the box
16
+ * so running deploys self-heal on the next expose, and
17
+ * - the off-path reuse-hint compares against it (records no longer equal it,
18
+ * so the hint always includes `--tunnel-name`, which is now correct).
19
+ */
5
20
  export const DEFAULT_TUNNEL_NAME = "parachute";
6
21
 
22
+ /**
23
+ * Derive a dedicated, per-hostname tunnel name from a hostname. Cloudflare
24
+ * tunnels are account-wide, so each machine/hostname needs its OWN tunnel —
25
+ * sharing one name across boxes collides their connectors (#491). The name is
26
+ * deterministic (same hostname → same name) so re-exposing the same hostname
27
+ * is idempotent: it finds and reuses the tunnel it created last time.
28
+ *
29
+ * Sanitization: lowercase, dots → hyphens, drop anything outside `[a-z0-9_-]`,
30
+ * then prefix `parachute-`. Examples:
31
+ * `our.parachute.computer` → `parachute-our-parachute-computer`
32
+ * `vault.example.com` → `parachute-vault-example-com`
33
+ *
34
+ * Length: tunnel names must satisfy `isValidTunnelName` (≤64 chars). When the
35
+ * derived name would exceed 64, truncate the sanitized body and append a short
36
+ * stable suffix (`-<8-hex>`) computed deterministically from the FULL hostname
37
+ * so two long hostnames sharing a 64-char prefix can't collide on the same
38
+ * tunnel. The hash is a non-crypto FNV-1a-style fold — deterministic, no
39
+ * Math.random / Date dependency (those would break idempotent re-expose).
40
+ */
41
+ const TUNNEL_NAME_PREFIX = "parachute-";
42
+ const MAX_TUNNEL_NAME = 64;
43
+
44
+ function shortStableHash(input: string): string {
45
+ // FNV-1a 32-bit. Deterministic, dependency-free, good enough to disambiguate
46
+ // two hostnames that sanitize to the same truncated prefix. >>> 0 keeps it
47
+ // unsigned so the hex is stable across runtimes.
48
+ let h = 0x811c9dc5;
49
+ for (let i = 0; i < input.length; i++) {
50
+ h ^= input.charCodeAt(i);
51
+ h = Math.imul(h, 0x01000193);
52
+ }
53
+ return (h >>> 0).toString(16).padStart(8, "0");
54
+ }
55
+
56
+ export function deriveTunnelName(hostname: string): string {
57
+ const body = hostname
58
+ .toLowerCase()
59
+ .replace(/\./g, "-")
60
+ .replace(/[^a-z0-9_-]/g, "");
61
+ const full = `${TUNNEL_NAME_PREFIX}${body}`;
62
+ if (full.length <= MAX_TUNNEL_NAME) return full;
63
+ // Too long — truncate the body and append a stable 8-hex suffix derived from
64
+ // the full hostname. Reserve room for the prefix + "-" + 8 hex chars.
65
+ const suffix = `-${shortStableHash(hostname)}`;
66
+ const room = MAX_TUNNEL_NAME - TUNNEL_NAME_PREFIX.length - suffix.length;
67
+ // Strip any trailing hyphen the truncation left behind (e.g. a slice that
68
+ // lands on a dot-turned-hyphen) so the body doesn't abut the suffix as `--`.
69
+ const truncated = body.slice(0, room).replace(/-+$/, "");
70
+ return `${TUNNEL_NAME_PREFIX}${truncated}${suffix}`;
71
+ }
72
+
7
73
  /**
8
74
  * Per-tunnel config + log file paths. Each tunnel gets its own subdirectory
9
75
  * under `~/.parachute/cloudflared/<tunnelName>/` so multiple tunnels on one
10
76
  * box don't trample each other's config.yml or interleave log lines.
11
77
  *
12
- * The default tunnel ("parachute") lives at
13
- * `~/.parachute/cloudflared/parachute/{config.yml,cloudflared.log}` a
14
- * location change from pre-#32 (`~/.parachute/cloudflared/config.yml`).
78
+ * The per-hostname tunnel (`deriveTunnelName(host)`, e.g.
79
+ * `parachute-our-parachute-computer`) lives at
80
+ * `~/.parachute/cloudflared/<tunnelName>/{config.yml,cloudflared.log}`.
15
81
  * Re-running `parachute expose public --cloudflare` regenerates the file
16
- * at the new path; the legacy file is left in place but unused.
82
+ * at that path; any legacy `parachute/` file is left in place but unused.
17
83
  *
18
84
  * `configDir` overrides the base (`~/.parachute` by default). Tests pass a
19
85
  * tmp dir so per-tunnel-derived paths never resolve against the operator's