@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
|
@@ -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 {
|
|
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.
|
|
273
|
-
*
|
|
274
|
-
*
|
|
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
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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 (!
|
|
976
|
+
if (!state) {
|
|
838
977
|
clearExposeState(r.exposeStatePath);
|
|
839
978
|
}
|
|
840
|
-
|
|
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
|
|
373
|
-
|
|
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.
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -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`
|
|
1197
|
-
*
|
|
1198
|
-
*
|
|
1199
|
-
*
|
|
1200
|
-
*
|
|
1201
|
-
*
|
|
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`
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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'
|
|
150
|
-
*
|
|
151
|
-
* `
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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
|
|
158
|
-
* - `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
|
|
169
|
+
* code doesn't understand should not be treated as broad.)
|
|
163
170
|
*
|
|
164
|
-
*
|
|
165
|
-
* the
|
|
166
|
-
*
|
|
167
|
-
*
|
|
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
|
}
|