@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.
@@ -1,7 +1,12 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { mkdirSync, openSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
- import { DEFAULT_TUNNEL_NAME, cloudflaredPathsFor, writeConfig } from "../cloudflare/config.ts";
4
+ import {
5
+ DEFAULT_TUNNEL_NAME,
6
+ cloudflaredPathsFor,
7
+ deriveTunnelName,
8
+ writeConfig,
9
+ } from "../cloudflare/config.ts";
5
10
  import {
6
11
  DEFAULT_CLOUDFLARED_HOME,
7
12
  cloudflaredInstallHint,
@@ -10,6 +15,7 @@ import {
10
15
  } from "../cloudflare/detect.ts";
11
16
  import {
12
17
  CLOUDFLARED_STATE_PATH,
18
+ type CloudflaredState,
13
19
  type CloudflaredTunnelRecord,
14
20
  clearCloudflaredState,
15
21
  findTunnelRecord,
@@ -269,9 +275,12 @@ export interface ExposeCloudflareOpts {
269
275
  */
270
276
  exposeStatePath?: string;
271
277
  /**
272
- * Tunnel name targeted by this invocation. Defaults to `parachute` —
273
- * the canonical single-tunnel name. Override to run multiple tunnels on
274
- * one box (#32).
278
+ * Tunnel name targeted by this invocation. The up-path defaults to a
279
+ * per-hostname derived name (`deriveTunnelName(hostname)`) so each machine
280
+ * gets its own tunnel and account-wide tunnels don't collide across boxes
281
+ * (#491). Override to pin a specific name (e.g. multiple tunnels on one
282
+ * box, #32). The off-path resolves the name from `cloudflared-state.json`
283
+ * when omitted (it has no hostname to derive from).
275
284
  */
276
285
  tunnelName?: string;
277
286
  /**
@@ -366,8 +375,20 @@ interface Resolved {
366
375
  restartService: (short: string) => Promise<number>;
367
376
  }
368
377
 
369
- function resolve(opts: ExposeCloudflareOpts): Resolved {
370
- const tunnelName = opts.tunnelName ?? DEFAULT_TUNNEL_NAME;
378
+ /**
379
+ * Resolve options into the fully-defaulted `Resolved` shape.
380
+ *
381
+ * `tunnelNameDefault` is the fallback tunnel name when the caller didn't pass
382
+ * an explicit `opts.tunnelName`. The up-path passes `deriveTunnelName(hostname)`
383
+ * so each machine/hostname gets its OWN dedicated tunnel (#491) — sharing one
384
+ * account-wide tunnel across boxes collides their connectors. An explicit
385
+ * `--tunnel-name` always wins (operators can override). The off-path has no
386
+ * hostname to derive from, so it resolves the name from state before calling
387
+ * in (see `exposeCloudflareOff`) and only relies on this default as a last
388
+ * resort.
389
+ */
390
+ function resolve(opts: ExposeCloudflareOpts, tunnelNameDefault: string): Resolved {
391
+ const tunnelName = opts.tunnelName ?? tunnelNameDefault;
371
392
  const configDir = opts.configDir ?? CONFIG_DIR;
372
393
  // Derive per-tunnel config/log paths from the *resolved* configDir, not the
373
394
  // real `CONFIG_DIR`. When a test threads a tmp `configDir` but omits explicit
@@ -489,7 +510,12 @@ export async function exposeCloudflareUp(
489
510
  hostname: string,
490
511
  opts: ExposeCloudflareOpts = {},
491
512
  ): Promise<number> {
492
- const r = resolve(opts);
513
+ // Default to a per-hostname dedicated tunnel (#491). An explicit
514
+ // `--tunnel-name` still wins (handled inside `resolve`). Deriving from the
515
+ // hostname keeps re-expose idempotent (same hostname → same name → reuse the
516
+ // tunnel created last time) and stops two machines from colliding on the
517
+ // single account-wide `"parachute"` tunnel.
518
+ const r = resolve(opts, deriveTunnelName(hostname));
493
519
 
494
520
  if (!isValidTunnelName(r.tunnelName)) {
495
521
  r.log(
@@ -591,6 +617,9 @@ export async function exposeCloudflareUp(
591
617
  return reportCloudflaredError(err, r.log);
592
618
  }
593
619
  r.log(`✓ Created tunnel ${tunnel.id}`);
620
+ r.log(
621
+ " Each machine gets its own dedicated tunnel — you don't need to run `cloudflared tunnel create` separately; expose does it.",
622
+ );
594
623
  } else {
595
624
  r.log(`✓ Reusing existing tunnel "${r.tunnelName}" (${tunnel.id})`);
596
625
  }
@@ -672,6 +701,40 @@ export async function exposeCloudflareUp(
672
701
  }
673
702
  }
674
703
 
704
+ // Legacy shared-tunnel migration sweep (#491). Aaron's running boxes were
705
+ // exposed under the old single account-wide `"parachute"` tunnel; the bug
706
+ // was that a second box reusing that name collided connectors. Now that the
707
+ // default is per-hostname, a box upgrading and re-exposing will create/route
708
+ // a NEW dedicated tunnel — but the OLD `"parachute"` connector is still
709
+ // running, still registered on the shared tunnel, still able to pick up
710
+ // load-balanced requests for OTHER hosts. Kill it + drop its state record so
711
+ // the box self-heals immediately on this expose instead of at the next
712
+ // reboot. Only fires when (a) we actually migrated AWAY from "parachute"
713
+ // (the new derived name differs) and (b) a live legacy record exists.
714
+ // `routeDns` above already used `--overwrite-dns`, so this hostname's CNAME
715
+ // has been repointed to the new tunnel — the legacy connector can't serve it
716
+ // anymore regardless; this just stops it from serving anyone else's.
717
+ let migratedState = stateBefore;
718
+ if (r.tunnelName !== DEFAULT_TUNNEL_NAME) {
719
+ const legacy = findTunnelRecord(stateBefore, DEFAULT_TUNNEL_NAME);
720
+ if (legacy) {
721
+ if (r.alive(legacy.pid)) {
722
+ try {
723
+ r.kill(legacy.pid, "SIGTERM");
724
+ } catch {
725
+ // Already gone between read and kill — fine; we drop the record below.
726
+ }
727
+ r.log(
728
+ `Stopped legacy shared-tunnel connector (migrated ${hostname} to dedicated tunnel ${r.tunnelName}).`,
729
+ );
730
+ }
731
+ // Drop the legacy shared-tunnel record whether or not its connector was
732
+ // still alive. A dead record would otherwise linger across re-exposes
733
+ // until the next `off`; clearing it here keeps state tidy (#491 review).
734
+ migratedState = withoutTunnelRecord(stateBefore, DEFAULT_TUNNEL_NAME);
735
+ }
736
+ }
737
+
675
738
  const pid = r.spawner.spawn(
676
739
  ["cloudflared", "tunnel", "--config", r.configPath, "run"],
677
740
  r.logPath,
@@ -685,7 +748,7 @@ export async function exposeCloudflareUp(
685
748
  startedAt: r.now().toISOString(),
686
749
  configPath: r.configPath,
687
750
  };
688
- writeCloudflaredState(withTunnelRecord(stateBefore, record), r.statePath);
751
+ writeCloudflaredState(withTunnelRecord(migratedState, record), r.statePath);
689
752
 
690
753
  // Persist the shared cross-provider expose record. Without this, the
691
754
  // Tailscale path was the only one writing expose-state.json — so after a
@@ -763,12 +826,20 @@ export async function exposeCloudflareUp(
763
826
 
764
827
  r.log("");
765
828
  r.log(`✓ Cloudflare tunnel up (pid ${pid}).`);
829
+ r.log(` Tunnel: ${r.tunnelName} (dedicated to this machine)`);
766
830
  r.log(` Open: ${baseUrl}/`);
767
831
  r.log(` Admin: ${baseUrl}/admin/`);
768
832
  r.log(` Vault: ${vaultUrl}`);
769
833
  r.log(` OAuth: ${hubOrigin}`);
770
834
  r.log(` Logs: ${r.logPath}`);
771
835
  r.log("");
836
+ // Honest reboot caveat: the connector is a detached background process, not
837
+ // yet a launchd/systemd service, so it does NOT survive a reboot (durable
838
+ // connector is a tracked follow-up). Re-running the same command brings it
839
+ // back idempotently — same hostname → same dedicated tunnel.
840
+ r.log("Note: the connector runs in the background but does not survive a reboot yet. After a");
841
+ r.log(`reboot, re-run: parachute expose public --cloudflare --domain ${hostname}`);
842
+ r.log("");
772
843
  r.log("Point a claude.ai / ChatGPT connector at:");
773
844
  r.log(` ${vaultUrl}`);
774
845
  printAuthGuidance(r.log, vaultUrl);
@@ -784,30 +855,27 @@ export async function exposeCloudflareUp(
784
855
  return 0;
785
856
  }
786
857
 
787
- export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Promise<number> {
788
- const r = resolve(opts);
789
- const stateBefore = readCloudflaredState(r.statePath);
790
- const record = findTunnelRecord(stateBefore, r.tunnelName);
791
- if (!record) {
792
- if (stateBefore && Object.keys(stateBefore.tunnels).length > 0) {
793
- const others = listTunnelRecords(stateBefore)
794
- .map((t) => t.tunnelName)
795
- .join(", ");
796
- r.log(
797
- `No Cloudflare exposure recorded for tunnel "${r.tunnelName}". Other tunnels: ${others}.`,
798
- );
799
- } else {
800
- r.log("No Cloudflare exposure recorded. Nothing to tear down.");
801
- }
802
- return 0;
803
- }
858
+ /**
859
+ * Tear down ONE tunnel record: SIGTERM its connector, sweep any orphan
860
+ * connectors for it (hub#487), drop its state record, and emit the
861
+ * reuse-hint copy. Pure-ish over `r` + the current state: returns the state
862
+ * with the record removed (or undefined when that empties it) plus an exit
863
+ * code, so the caller commits the disk write once after tearing down one or
864
+ * many tunnels. The connector kill is non-fatal-on-already-gone, fatal only
865
+ * when SIGTERM itself errors on a live pid.
866
+ */
867
+ function teardownOne(
868
+ r: Resolved,
869
+ state: CloudflaredState | undefined,
870
+ record: CloudflaredTunnelRecord,
871
+ ): { state: CloudflaredState | undefined; code: number } {
804
872
  if (r.alive(record.pid)) {
805
873
  try {
806
874
  r.kill(record.pid, "SIGTERM");
807
- r.log(`✓ Stopped cloudflared (pid ${record.pid}).`);
875
+ r.log(`✓ Stopped cloudflared (pid ${record.pid}, tunnel "${record.tunnelName}").`);
808
876
  } catch (err) {
809
877
  r.log(`✗ Failed to stop cloudflared: ${err instanceof Error ? err.message : String(err)}`);
810
- return 1;
878
+ return { state, code: 1 };
811
879
  }
812
880
  } else {
813
881
  r.log(`cloudflared (pid ${record.pid}) wasn't running; clearing stale state.`);
@@ -824,9 +892,80 @@ export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Prom
824
892
  // Already gone between probe and kill — fine.
825
893
  }
826
894
  }
827
- const stateAfter = withoutTunnelRecord(stateBefore, r.tunnelName);
828
- if (stateAfter) {
829
- writeCloudflaredState(stateAfter, r.statePath);
895
+ r.log(` ${record.hostname} is no longer reachable through this machine.`);
896
+ r.log(
897
+ ` Tunnel "${record.tunnelName}" (${record.tunnelUuid}) remains defined in Cloudflare; re-running`,
898
+ );
899
+ // Only suggest `--tunnel-name` for a custom name. The auto-derived name
900
+ // (and the legacy shared "parachute" name) need no flag — re-running with
901
+ // just --domain re-derives the per-hostname name (and migrates a legacy
902
+ // record off the shared tunnel), which is exactly what we want.
903
+ const isAutoName =
904
+ record.tunnelName === deriveTunnelName(record.hostname) ||
905
+ record.tunnelName === DEFAULT_TUNNEL_NAME;
906
+ r.log(
907
+ ` \`parachute expose public --cloudflare --domain ${record.hostname}${isAutoName ? "" : ` --tunnel-name ${record.tunnelName}`}\` reuses it.`,
908
+ );
909
+ return { state: withoutTunnelRecord(state, record.tunnelName), code: 0 };
910
+ }
911
+
912
+ export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Promise<number> {
913
+ // The off-path has no hostname to derive a name from. When `--tunnel-name`
914
+ // is set we use it; otherwise we resolve from cloudflared-state.json (below).
915
+ // `DEFAULT_TUNNEL_NAME` is only the inert `resolve` fallback here — the
916
+ // state-driven branch never relies on it.
917
+ const r = resolve(opts, DEFAULT_TUNNEL_NAME);
918
+ const stateBefore = readCloudflaredState(r.statePath);
919
+ const records = listTunnelRecords(stateBefore);
920
+
921
+ // Decide which records to tear down.
922
+ // - explicit `--tunnel-name` → exactly that one (or a not-found message).
923
+ // - no flag, 0 tunnels → nothing to do.
924
+ // - no flag, exactly 1 → that one.
925
+ // - no flag, ≥2 → ALL of them. A bare `expose public
926
+ // --cloudflare off` means "stop all public Cloudflare exposure on this
927
+ // machine"; tearing down only one would leave the box half-exposed with
928
+ // no obvious signal which tunnel survived.
929
+ let targets: CloudflaredTunnelRecord[];
930
+ if (opts.tunnelName !== undefined) {
931
+ const record = findTunnelRecord(stateBefore, r.tunnelName);
932
+ if (!record) {
933
+ if (records.length > 0) {
934
+ const others = records.map((t) => t.tunnelName).join(", ");
935
+ r.log(
936
+ `No Cloudflare exposure recorded for tunnel "${r.tunnelName}". Other tunnels: ${others}.`,
937
+ );
938
+ } else {
939
+ r.log("No Cloudflare exposure recorded. Nothing to tear down.");
940
+ }
941
+ return 0;
942
+ }
943
+ targets = [record];
944
+ } else {
945
+ if (records.length === 0) {
946
+ r.log("No Cloudflare exposure recorded. Nothing to tear down.");
947
+ return 0;
948
+ }
949
+ if (records.length > 1) {
950
+ r.log(
951
+ `Tearing down all ${records.length} recorded Cloudflare tunnels: ${records
952
+ .map((t) => t.tunnelName)
953
+ .join(", ")}.`,
954
+ );
955
+ }
956
+ targets = records;
957
+ }
958
+
959
+ let state = stateBefore;
960
+ let failed = false;
961
+ for (const record of targets) {
962
+ const result = teardownOne(r, state, record);
963
+ state = result.state;
964
+ if (result.code !== 0) failed = true;
965
+ }
966
+
967
+ if (state) {
968
+ writeCloudflaredState(state, r.statePath);
830
969
  } else {
831
970
  clearCloudflaredState(r.statePath);
832
971
  }
@@ -834,17 +973,10 @@ export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Prom
834
973
  // downstream consumers stop resolving the now-dead public URL (mirrors the
835
974
  // up-path write above + the Tailscale off-path's expose-state teardown). When
836
975
  // other tunnels survive we leave it — a later off for the last one clears it.
837
- if (!stateAfter) {
976
+ if (!state) {
838
977
  clearExposeState(r.exposeStatePath);
839
978
  }
840
- r.log(` ${record.hostname} is no longer reachable through this machine.`);
841
- r.log(
842
- ` Tunnel "${record.tunnelName}" (${record.tunnelUuid}) remains defined in Cloudflare; re-running`,
843
- );
844
- r.log(
845
- ` \`parachute expose public --cloudflare --domain ${record.hostname}${record.tunnelName === DEFAULT_TUNNEL_NAME ? "" : ` --tunnel-name ${record.tunnelName}`}\` reuses it.`,
846
- );
847
- return 0;
979
+ return failed ? 1 : 0;
848
980
  }
849
981
 
850
982
  function reportCloudflaredError(err: unknown, log: (line: string) => void): number {
package/src/help.ts CHANGED
@@ -369,8 +369,13 @@ Flags:
369
369
  --domain <hostname> fully-qualified hostname to route through the tunnel
370
370
  (e.g. vault.example.com). The apex must be a zone on
371
371
  your Cloudflare account.
372
- --tunnel-name <name> Cloudflare tunnel name (default: \`parachute\`).
373
- Use to coexist multiple named tunnels on one box.
372
+ --tunnel-name <name> Cloudflare tunnel name. Defaults to a per-hostname
373
+ name (e.g. vault.example.com parachute-vault-example-com)
374
+ so each machine gets its OWN dedicated tunnel —
375
+ Cloudflare tunnels are account-wide, and sharing one
376
+ across machines collides their connectors. You don't
377
+ need to create the tunnel yourself; expose does it.
378
+ Override only to pin a specific name.
374
379
  --skip-provider-check bypass non-TTY auto-detect, default to Tailscale
375
380
  Funnel as before. Intended for CI / scripts whose
376
381
  environment is already pre-flighted.
@@ -1193,12 +1193,14 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
1193
1193
  * - The authority source of truth today is `isFirstAdmin` for owner-wide
1194
1194
  * authority and `user_vaults.role` (via `vaultVerbsForRole`) for assigned
1195
1195
  * users.
1196
- * - `vaultVerbsForRole` provably never returns `admin` for an assigned user
1197
- * (it maps write→[read,write], read→[read], unknown→[]), so this helper
1198
- * drops `vault:<name>:admin` for every non-owner BY CONSTRUCTION without
1199
- * hardcoding "drop admin". It reads the held verb set and admits only
1200
- * held verbs, so it's forward-compatible: if a future role ever granted
1201
- * admin, the cap would admit it automatically.
1196
+ * - `vaultVerbsForRole` maps write→[read,write,admin] (2026-05-30: any
1197
+ * assigned user holds FULL vault authority, incl. admin), read→[read],
1198
+ * unknown→[]. This helper reads the held verb set and admits ONLY held
1199
+ * verbs no hardcoded allow/deny of admin. So an assigned user gets
1200
+ * `vault:<their-vault>:admin`, while a user gets NOTHING for a vault they
1201
+ * aren't assigned (held=null every verb dropped). The cap is the
1202
+ * forward-compatible single source: it admitted admin automatically the
1203
+ * moment the role mapping changed — no edit here was needed.
1202
1204
  * - Applied inside `issueAuthCodeRedirect` (the single choke-point ALL mint
1203
1205
  * paths funnel through: consent-submit, skip-consent, and same-hub
1204
1206
  * auto-trust), the CAPPED set is what gets both recorded (`recordGrant`)
@@ -152,10 +152,13 @@ export const NON_REQUESTABLE_SCOPES: ReadonlySet<string> = new Set([
152
152
  * enforced at the shared mint choke-point (`capScopesToUserAuthority` applied
153
153
  * inside `issueAuthCodeRedirect` in `oauth-handlers.ts`): an OAuth flow caps
154
154
  * named vault verbs to those the consenting user actually holds on that vault.
155
- * `vaultVerbsForRole` never returns `admin` for an assigned (read/write) user,
156
- * so admin is dropped for everyone except the hub owner (isFirstAdmin) who
157
- * holds admin everywhere by construction. An admin-only request from a
158
- * non-owner is refused outright (never minted as a zero-scope token).
155
+ * `vaultVerbsForRole` returns admin for an assigned user (2026-05-30: any
156
+ * assigned user holds full vault authority on their own vault), so a non-owner
157
+ * can delegate `vault:<their-vault>:admin` to their client. The cap still
158
+ * drops admin (and every verb) for a vault the user is NOT assigned to
159
+ * (held=null), and an admin-only request the cap empties is refused outright
160
+ * (never minted as a zero-scope token). The hub owner (isFirstAdmin) holds
161
+ * admin everywhere by construction.
159
162
  *
160
163
  * `vault:<name>:admin` also remains mintable by operator-proving local paths,
161
164
  * all of which require already-established authority:
package/src/users.ts CHANGED
@@ -146,30 +146,37 @@ function readVaultsForUser(db: Database, userId: string): string[] {
146
146
 
147
147
  /**
148
148
  * The per-vault verbs a `user_vaults.role` grants. The schema's `role`
149
- * column is `TEXT NOT NULL DEFAULT 'write'` and is reserved forward-compat
150
- * for per-vault role granularity (see the v10 migration note in
151
- * `hub-db.ts`). Today every assignment is created with `role = 'write'`, so
152
- * the only live mapping is `write → {read, write}`. The function is the
153
- * single place the verb-cap lives so a future role taxonomy (`read`-only,
154
- * `admin`, etc.) lands here without the friend-mint path having to change.
149
+ * column is `TEXT NOT NULL DEFAULT 'write'`; today every assignment is created
150
+ * with `role = 'write'`. This is the single place the verb-cap lives, so the
151
+ * OAuth mint cap (`capScopesToUserAuthority`) and the `/account` mint UI both
152
+ * read authority from here.
153
+ *
154
+ * **Assigned users hold FULL vault authority (read + write + admin)** as of
155
+ * 2026-05-30 (Aaron's call: "any assigned user gets admin"). The point of the
156
+ * multi-user flow is that someone given a vault — owned or shared — can connect
157
+ * their own client (e.g. Claude MCP) to it and grant everything they'd want,
158
+ * including `vault:<name>:admin` (token creation + config). Owner-vs-shared is
159
+ * NOT distinguished today; a shared user gets admin too (explicit trade-off).
155
160
  *
156
161
  * Mapping:
157
- * - `write` (today's only value) → `["read", "write"]`
158
- * - `read` → `["read"]`
162
+ * - `write` (today's default) → `["read", "write", "admin"]`
163
+ * - `read` (forward-compat) → `["read"]` — a *deliberate* read-only
164
+ * assignment stays read-only even under the any-assigned-user-gets-admin
165
+ * policy. Not created by any flow today.
159
166
  * - anything else (unknown role) → `[]` — fail closed. An unrecognised
160
167
  * role grants no minting authority rather than silently defaulting to
161
168
  * write. (Defense-in-depth: a hand-edited / future row with a role this
162
- * code doesn't understand should not be treated as broad write.)
169
+ * code doesn't understand should not be treated as broad.)
163
170
  *
164
- * `admin` is intentionally NOT mapped to a `vault:<name>:admin` mint here —
165
- * the friend-facing token mint is capped at read/write by design. A
166
- * future per-vault-admin friend grant would route through the
167
- * vault-admin-token path, not this one.
171
+ * Scope of the widening: this only affects `vault:<name>:<verb>` for vaults
172
+ * the user is assigned. Hub-level admin (`hub:admin`) + host operator scopes
173
+ * (`parachute:host:*`) are NOT vault scopes and remain ungrantable by
174
+ * non-admins — the cap's named-vault branch is the only thing this touches.
168
175
  */
169
- export type VaultVerb = "read" | "write";
176
+ export type VaultVerb = "read" | "write" | "admin";
170
177
 
171
178
  export function vaultVerbsForRole(role: string): VaultVerb[] {
172
- if (role === "write") return ["read", "write"];
179
+ if (role === "write") return ["read", "write", "admin"];
173
180
  if (role === "read") return ["read"];
174
181
  return [];
175
182
  }