@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
|
@@ -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
|
|
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
|
|
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
|
|
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).
|
|
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 →
|
|
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
|
-
//
|
|
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
|
-
|
|
8604
|
-
expect(
|
|
8605
|
-
|
|
8606
|
-
expect(
|
|
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
|
|
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
|
|
8636
|
-
//
|
|
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).
|
|
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
|
|
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
|
-
|
|
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
|
|
8782
|
-
//
|
|
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:
|
|
8800
|
+
"vault:other:admin",
|
|
8794
8801
|
challenge,
|
|
8795
8802
|
);
|
|
8796
|
-
|
|
8797
|
-
|
|
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
|
|
8801
|
-
// blocks the silent path); no grant
|
|
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:
|
|
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:
|
|
8835
|
+
expect(g?.scopes ?? []).not.toContain("vault:other:admin");
|
|
8827
8836
|
}
|
|
8828
8837
|
|
|
8829
|
-
// Path C — skip-consent:
|
|
8830
|
-
//
|
|
8831
|
-
//
|
|
8832
|
-
//
|
|
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, [
|
|
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:
|
|
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
|
|
8868
|
-
//
|
|
8869
|
-
//
|
|
8870
|
-
//
|
|
8871
|
-
// skip-consent gate fires (the requested
|
|
8872
|
-
//
|
|
8873
|
-
//
|
|
8874
|
-
//
|
|
8875
|
-
//
|
|
8876
|
-
|
|
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"]); //
|
|
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:
|
|
8889
|
-
// coverage check would pass for a direct admin request).
|
|
8890
|
-
recordGrant(db, friend.id, reg.client.clientId, ["vault:work:write", "vault:
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/account-home-ui.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
203
|
-
// else
|
|
204
|
-
//
|
|
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
|
|
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
|
-
//
|
|
222
|
-
//
|
|
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
|
package/src/cloudflare/config.ts
CHANGED
|
@@ -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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|