@openparachute/hub 0.5.13 → 0.5.14-rc.10

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.
Files changed (101) hide show
  1. package/README.md +109 -15
  2. package/package.json +2 -2
  3. package/src/__tests__/account-home-ui.test.ts +205 -0
  4. package/src/__tests__/admin-handlers.test.ts +74 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  6. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  7. package/src/__tests__/admin-vaults.test.ts +70 -4
  8. package/src/__tests__/api-account.test.ts +191 -1
  9. package/src/__tests__/api-mint-token.test.ts +682 -3
  10. package/src/__tests__/api-modules-config.test.ts +16 -10
  11. package/src/__tests__/api-modules-ops.test.ts +97 -0
  12. package/src/__tests__/api-modules.test.ts +100 -83
  13. package/src/__tests__/api-ready.test.ts +135 -0
  14. package/src/__tests__/api-revoke-token.test.ts +384 -0
  15. package/src/__tests__/api-users.test.ts +390 -13
  16. package/src/__tests__/chrome-strip.test.ts +15 -15
  17. package/src/__tests__/cli.test.ts +7 -5
  18. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  19. package/src/__tests__/expose-auth-preflight.test.ts +58 -50
  20. package/src/__tests__/expose-cloudflare.test.ts +114 -3
  21. package/src/__tests__/expose-interactive.test.ts +10 -4
  22. package/src/__tests__/expose-public-auto.test.ts +5 -1
  23. package/src/__tests__/expose.test.ts +49 -1
  24. package/src/__tests__/hub-db.test.ts +194 -29
  25. package/src/__tests__/hub-server.test.ts +322 -33
  26. package/src/__tests__/hub.test.ts +11 -0
  27. package/src/__tests__/init.test.ts +827 -0
  28. package/src/__tests__/lifecycle.test.ts +33 -1
  29. package/src/__tests__/migrate.test.ts +433 -51
  30. package/src/__tests__/notes-redirect.test.ts +20 -20
  31. package/src/__tests__/oauth-handlers.test.ts +1060 -29
  32. package/src/__tests__/oauth-ui.test.ts +12 -1
  33. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  34. package/src/__tests__/proxy-state.test.ts +192 -0
  35. package/src/__tests__/resource-binding.test.ts +97 -0
  36. package/src/__tests__/scope-explanations.test.ts +36 -0
  37. package/src/__tests__/serve.test.ts +9 -9
  38. package/src/__tests__/services-manifest.test.ts +40 -40
  39. package/src/__tests__/setup-wizard.test.ts +1114 -66
  40. package/src/__tests__/setup.test.ts +1 -1
  41. package/src/__tests__/status.test.ts +39 -0
  42. package/src/__tests__/users.test.ts +396 -9
  43. package/src/__tests__/vault-auth-status.test.ts +271 -11
  44. package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
  45. package/src/__tests__/well-known.test.ts +9 -9
  46. package/src/__tests__/wizard.test.ts +372 -0
  47. package/src/account-home-ui.ts +547 -0
  48. package/src/admin-handlers.ts +49 -17
  49. package/src/admin-host-admin-token.ts +25 -0
  50. package/src/admin-login-ui.ts +4 -4
  51. package/src/admin-vault-admin-token.ts +17 -0
  52. package/src/admin-vaults.ts +48 -15
  53. package/src/api-account.ts +72 -6
  54. package/src/api-mint-token.ts +132 -24
  55. package/src/api-modules-ops.ts +52 -16
  56. package/src/api-modules.ts +31 -14
  57. package/src/api-ready.ts +102 -0
  58. package/src/api-revoke-token.ts +107 -21
  59. package/src/api-users.ts +497 -58
  60. package/src/bun-link.ts +55 -0
  61. package/src/chrome-strip.ts +6 -6
  62. package/src/cli.ts +93 -24
  63. package/src/cloudflare/config.ts +10 -4
  64. package/src/cloudflare/detect.ts +73 -6
  65. package/src/commands/expose-auth-preflight.ts +55 -63
  66. package/src/commands/expose-cloudflare.ts +114 -10
  67. package/src/commands/expose-interactive.ts +10 -11
  68. package/src/commands/expose-public-auto.ts +6 -4
  69. package/src/commands/expose.ts +8 -0
  70. package/src/commands/init.ts +563 -0
  71. package/src/commands/install.ts +41 -23
  72. package/src/commands/lifecycle.ts +12 -0
  73. package/src/commands/migrate.ts +293 -41
  74. package/src/commands/status.ts +10 -1
  75. package/src/commands/wizard.ts +843 -0
  76. package/src/env-file.ts +10 -0
  77. package/src/help.ts +157 -17
  78. package/src/hub-db.ts +42 -0
  79. package/src/hub-server.ts +136 -23
  80. package/src/hub-settings.ts +13 -2
  81. package/src/hub.ts +16 -9
  82. package/src/notes-redirect.ts +5 -5
  83. package/src/oauth-handlers.ts +342 -173
  84. package/src/oauth-ui.ts +28 -2
  85. package/src/proxy-error-ui.ts +506 -0
  86. package/src/proxy-state.ts +131 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +94 -5
  90. package/src/service-spec.ts +39 -18
  91. package/src/setup-wizard.ts +1173 -117
  92. package/src/users.ts +307 -29
  93. package/src/vault/auth-status.ts +152 -25
  94. package/src/vault-hub-origin-env.ts +100 -0
  95. package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
  96. package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  99. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  100. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  101. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -1,29 +1,47 @@
1
1
  /**
2
- * Read-only probe of vault's auth state, for the post-exposure preflight
3
- * nudge. We don't want to lock the DB or mutate anything — this is a
4
- * one-shot "should we warn the user their vault is wide open on the public
2
+ * Read-only probe of operator auth state, for the post-exposure preflight
3
+ * nudge. We don't want to lock anything or mutate state — this is a one-
4
+ * shot "should we warn the user their vault is wide open on the public
5
5
  * internet?" check.
6
6
  *
7
- * Two sources:
8
- * 1. ~/.parachute/vault/config.yaml → owner_password_hash + totp_secret
9
- * 2. ~/.parachute/vault/data/<name>/vault.db (SQLite) → tokens table count
7
+ * Three sources, checked in this order:
10
8
  *
11
- * The YAML path uses line-anchored regex parsing that matches vault's own
12
- * `readGlobalConfig()` semantics (parachute-vault src/config.ts): keys are
13
- * optional, quoted scalars, and empty-string / missing-key both mean "not
14
- * configured." We mirror that rather than bringing in a YAML dependency.
9
+ * 1. ~/.parachute/hub.db → users table (authoritative since multi-user
10
+ * Phase 1, hub#252 / PRs 279–281 / 425). Hub-issued OAuth + browser
11
+ * sign-in both verify against `users.password_hash`. If any user row
12
+ * exists with a non-empty password_hash, the operator has an account
13
+ * "owner password set." The earliest-created user row is the canonical
14
+ * operator (cf. `getFirstAdminId` in src/users.ts).
15
+ * 2. ~/.parachute/vault/config.yaml → owner_password_hash + totp_secret
16
+ * (pre-multi-user Phase 1 location). Fallback for super-old installs
17
+ * whose hub.db is absent or empty.
18
+ * 3. ~/.parachute/vault/data/<name>/vault.db (SQLite) → tokens table
19
+ * count, summed across every vault instance.
15
20
  *
16
- * The SQLite path is best-effort: if the DB is missing, locked (vault is
17
- * writing), or the schema has drifted, `tokenCount` comes back as `null`
18
- * and the caller surfaces "token status unknown" rather than lying with a
19
- * false zero. The exposure flow has already succeeded by the time this
20
- * runs a probe failure must never block the user's happy path.
21
+ * The hub.db schema doesn't yet carry a TOTP column (2FA lands in a later
22
+ * phase per the multi-user design doc); we always report `hasTotp: false`
23
+ * when the hub.db path is the source of truth. That matches what's
24
+ * actually shipped pretending otherwise would whisper "you're covered"
25
+ * when no second factor exists.
21
26
  *
22
- * Schema coupling note: we read the `tokens` table by name with a bare
23
- * COUNT(*). If vault ever renames that table, that's a breaking change on
24
- * vault's side and this probe is the least of the fallout. Post-launch,
25
- * a public `/api/auth/status` endpoint on vault (tracked separately) would
26
- * let us drop this coupling entirely.
27
+ * The YAML fallback path uses line-anchored regex parsing that matches
28
+ * vault's own `readGlobalConfig()` semantics (parachute-vault src/config.ts):
29
+ * keys are optional, quoted scalars, and empty-string / missing-key both
30
+ * mean "not configured." We mirror that rather than bringing in a YAML
31
+ * dependency.
32
+ *
33
+ * The vault-token SQLite path is best-effort: if the DB is missing,
34
+ * locked (vault is writing), or the schema has drifted, `tokenCount`
35
+ * comes back as `null` and the caller surfaces "token status unknown"
36
+ * rather than lying with a false zero. The exposure flow has already
37
+ * succeeded by the time this runs — a probe failure must never block
38
+ * the user's happy path.
39
+ *
40
+ * Schema coupling note: we read the `tokens` table in each vault.db by
41
+ * name with a bare COUNT(*). If vault ever renames that table, that's a
42
+ * breaking change on vault's side and this probe is the least of the
43
+ * fallout. Post-launch, a public `/api/auth/status` endpoint on vault
44
+ * (tracked separately) would let us drop this coupling entirely.
27
45
  */
28
46
 
29
47
  import { existsSync, readFileSync, readdirSync } from "node:fs";
@@ -36,9 +54,12 @@ export interface VaultAuthStatus {
36
54
  hasTotp: boolean;
37
55
  /**
38
56
  * `null` means we couldn't read the SQLite DB — distinct from "0 tokens
39
- * exist." Callers branch the UI on this: `null` "token status unknown,
40
- * run `parachute vault tokens list` to check"; `0` loud "no auth at
41
- * all!" warning; `>0` benign.
57
+ * exist." Post the pvt_* DROP (hub#466) the expose preflight no longer
58
+ * branches on this `classify()` in expose-auth-preflight.ts gates on
59
+ * `hasOwnerPassword`, since access now flows through the owner password +
60
+ * on-demand hub JWT mint, not standing vault tokens. `tokenCount` is kept
61
+ * as a best-effort diagnostic only (these rows are vestigial); `null`
62
+ * still cleanly distinguishes "unreadable DB" from "0 rows."
42
63
  */
43
64
  tokenCount: number | null;
44
65
  /** Vault instance names discovered under data/. Empty when vault has
@@ -49,6 +70,8 @@ export interface VaultAuthStatus {
49
70
  export interface AuthStatusOpts {
50
71
  /** Override `~/.parachute/vault` for tests. */
51
72
  vaultHome?: string;
73
+ /** Override `~/.parachute/hub.db` for tests. */
74
+ hubDbPath?: string;
52
75
  /** Read a YAML file; defaults to `readFileSync(path, "utf8")`. Missing
53
76
  * file should return `undefined` (not throw) so callers can distinguish
54
77
  * "no password configured" from "IO error." */
@@ -60,13 +83,31 @@ export interface AuthStatusOpts {
60
83
  * thrown error (missing, locked, schema drift) is caught by the caller
61
84
  * and mapped to `tokenCount: null`. */
62
85
  countTokens?: (dbPath: string) => number;
86
+ /**
87
+ * Probe hub.db for "does at least one user row with a non-empty
88
+ * `password_hash` exist?" — the canonical "owner password is set"
89
+ * signal post-multi-user-Phase-1. Returning:
90
+ * - `true` → at least one user has a password_hash; hub.db is
91
+ * the source of truth, YAML fallback is skipped.
92
+ * - `false` → hub.db opened cleanly but users is empty (fresh
93
+ * install pre-wizard); fall back to YAML.
94
+ * - `undefined` → hub.db missing / unreadable / migration not yet
95
+ * applied; fall back to YAML (legacy install path).
96
+ * The split between `false` and `undefined` matters: an empty hub.db
97
+ * on a fresh wizard run should NOT be allowed to mask an owner_password_hash
98
+ * that the operator set via vault's pre-multi-user flow. Callers that
99
+ * want true "I can sign in" semantics get the OR of hub.db∪YAML.
100
+ */
101
+ probeHubDbHasUserPassword?: (dbPath: string) => boolean | undefined;
63
102
  }
64
103
 
65
104
  interface Resolved {
66
105
  vaultHome: string;
106
+ hubDbPath: string;
67
107
  readText: (path: string) => string | undefined;
68
108
  listVaultNames: (dataDir: string) => string[];
69
109
  countTokens: (dbPath: string) => number;
110
+ probeHubDbHasUserPassword: (dbPath: string) => boolean | undefined;
70
111
  }
71
112
 
72
113
  function defaultVaultHome(): string {
@@ -77,6 +118,11 @@ function defaultVaultHome(): string {
77
118
  return root.length > 0 ? join(root, "vault") : join(homedir(), ".parachute", "vault");
78
119
  }
79
120
 
121
+ function defaultHubDbPath(): string {
122
+ const root = configDir();
123
+ return root.length > 0 ? join(root, "hub.db") : join(homedir(), ".parachute", "hub.db");
124
+ }
125
+
80
126
  function defaultReadText(path: string): string | undefined {
81
127
  try {
82
128
  return readFileSync(path, "utf8");
@@ -112,12 +158,59 @@ function defaultCountTokens(dbPath: string): number {
112
158
  }
113
159
  }
114
160
 
161
+ /**
162
+ * Open hub.db readonly and ask "does at least one user have a non-empty
163
+ * password_hash?" Returns `undefined` on any failure (DB missing, locked,
164
+ * schema drift, users table absent because migration v2 hasn't applied) —
165
+ * indistinguishable from "no hub.db at all," which is what the caller
166
+ * wants for the YAML-fallback branch.
167
+ *
168
+ * We deliberately do NOT open hub.db read-write here — `openHubDb()`
169
+ * would run migrations as a side effect, and this is a read probe from
170
+ * an unrelated command (`parachute expose`). `readonly: true` skips the
171
+ * WAL handshake and won't contend with the live hub server.
172
+ */
173
+ function defaultProbeHubDbHasUserPassword(dbPath: string): boolean | undefined {
174
+ if (!existsSync(dbPath)) return undefined;
175
+ const { Database } = require("bun:sqlite");
176
+ let db: { prepare: (sql: string) => { get: () => unknown }; close: () => void } | undefined;
177
+ try {
178
+ db = new Database(dbPath, { readonly: true }) as typeof db;
179
+ // COUNT(*) over users with non-empty password_hash. `length(...) > 0`
180
+ // matches the "missing/empty hash" treatment from the YAML side.
181
+ //
182
+ // Why "any user with a hash" not "first admin specifically": friend
183
+ // accounts can only be created by an already-authenticated admin
184
+ // (per api-users.ts's host:admin gate), so any user-with-hash
185
+ // implies the first admin has one too. Equivalent in practice and
186
+ // simpler than a JOIN on earliest-created-at. A future env-seed
187
+ // flow that creates friend accounts before the operator sets a
188
+ // password would need to revisit this assumption.
189
+ const row = db
190
+ ?.prepare(
191
+ "SELECT COUNT(*) AS n FROM users WHERE password_hash IS NOT NULL AND length(password_hash) > 0",
192
+ )
193
+ .get() as { n: number } | null;
194
+ return (row?.n ?? 0) > 0;
195
+ } catch {
196
+ return undefined;
197
+ } finally {
198
+ try {
199
+ db?.close();
200
+ } catch {
201
+ // ignore
202
+ }
203
+ }
204
+ }
205
+
115
206
  function resolve(opts: AuthStatusOpts): Resolved {
116
207
  return {
117
208
  vaultHome: opts.vaultHome ?? defaultVaultHome(),
209
+ hubDbPath: opts.hubDbPath ?? defaultHubDbPath(),
118
210
  readText: opts.readText ?? defaultReadText,
119
211
  listVaultNames: opts.listVaultNames ?? defaultListVaultNames,
120
212
  countTokens: opts.countTokens ?? defaultCountTokens,
213
+ probeHubDbHasUserPassword: opts.probeHubDbHasUserPassword ?? defaultProbeHubDbHasUserPassword,
121
214
  };
122
215
  }
123
216
 
@@ -134,7 +227,12 @@ function matchQuotedKey(yaml: string, key: string): string | undefined {
134
227
  return captured;
135
228
  }
136
229
 
137
- function readGlobalAuth(r: Resolved): { hasOwnerPassword: boolean; hasTotp: boolean } {
230
+ interface AuthSignals {
231
+ hasOwnerPassword: boolean;
232
+ hasTotp: boolean;
233
+ }
234
+
235
+ function readYamlAuth(r: Resolved): AuthSignals {
138
236
  const yaml = r.readText(join(r.vaultHome, "config.yaml"));
139
237
  if (yaml === undefined) return { hasOwnerPassword: false, hasTotp: false };
140
238
  return {
@@ -143,6 +241,35 @@ function readGlobalAuth(r: Resolved): { hasOwnerPassword: boolean; hasTotp: bool
143
241
  };
144
242
  }
145
243
 
244
+ /**
245
+ * Combine the hub.db probe + the legacy YAML probe into a single auth-signals
246
+ * read. Logic:
247
+ *
248
+ * - hub.db says yes → operator has an account in the canonical store;
249
+ * report `hasOwnerPassword: true`. TOTP is reported per the YAML probe
250
+ * (so a legacy operator who set both YAML password + YAML totp_secret,
251
+ * then migrated to a hub.db user, still surfaces "TOTP is on" — we
252
+ * don't have a hub-side TOTP column yet, hub#252 Phase 3 lands it).
253
+ * - hub.db says no AND was reachable → users table is empty, no hub
254
+ * account exists yet. Fall back to YAML for both signals — a pre-
255
+ * multi-user install would have its password in YAML.
256
+ * - hub.db unreachable → can't tell, fall back to YAML entirely.
257
+ *
258
+ * Net effect: `hasOwnerPassword` is the OR of (hub.db has a user with a
259
+ * password) ∪ (YAML has owner_password_hash). Either source counts.
260
+ */
261
+ function readAuthSignals(r: Resolved): AuthSignals {
262
+ const yaml = readYamlAuth(r);
263
+ const hubDbHasUser = r.probeHubDbHasUserPassword(r.hubDbPath);
264
+ const hasOwnerPassword = hubDbHasUser === true ? true : yaml.hasOwnerPassword;
265
+ return {
266
+ hasOwnerPassword,
267
+ // No hub-side TOTP column shipped yet (multi-user Phase 3). Until it
268
+ // lands, TOTP is YAML-only — matches what's actually true on disk.
269
+ hasTotp: yaml.hasTotp,
270
+ };
271
+ }
272
+
146
273
  /**
147
274
  * Sum token counts across every vault instance found under data/. If any
148
275
  * probe throws (missing DB, locked, schema drift), the whole result
@@ -171,7 +298,7 @@ function readTotalTokenCount(r: Resolved, vaultNames: string[]): number | null {
171
298
 
172
299
  export function readVaultAuthStatus(opts: AuthStatusOpts = {}): VaultAuthStatus {
173
300
  const r = resolve(opts);
174
- const { hasOwnerPassword, hasTotp } = readGlobalAuth(r);
301
+ const { hasOwnerPassword, hasTotp } = readAuthSignals(r);
175
302
  const dataDir = join(r.vaultHome, "data");
176
303
  const vaultNames = r.listVaultNames(dataDir);
177
304
  const tokenCount = readTotalTokenCount(r, vaultNames);
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Persist the resolved hub PUBLIC origin into `<configDir>/vault/.env` so the
3
+ * launchd / systemd daemon validates hub-minted JWTs against it.
4
+ *
5
+ * The OAuth issuer-mismatch P0 (this module's reason to exist): hub stamps the
6
+ * public origin (e.g. `https://parachute-x.ts.net`) on every JWT it mints.
7
+ * Vault, as the resource server, validates the `iss` claim against
8
+ * `getHubOrigin()` (`parachute-vault/src/hub-jwt.ts`), which reads ONLY
9
+ * `PARACHUTE_HUB_ORIGIN` from its process env and falls back to loopback
10
+ * `http://127.0.0.1:1939` when unset.
11
+ *
12
+ * `parachute start|restart vault` injects `PARACHUTE_HUB_ORIGIN` into the
13
+ * *spawn* env (see `commands/lifecycle.ts`). But the real-world boot path on an
14
+ * owner-operated box is the autostart daemon: `parachute-vault init` registers
15
+ * a launchd agent (macOS) / systemd unit (Linux) with `KeepAlive`/`Restart`,
16
+ * and that daemon boots vault out-of-band via `~/.parachute/vault/start.sh`.
17
+ * The wrapper sources `~/.parachute/vault/.env` and never reads expose-state,
18
+ * and the launchd plist carries no `EnvironmentVariables`. So on every reboot
19
+ * or crash-restart, launchd starts vault with NO `PARACHUTE_HUB_ORIGIN`, vault
20
+ * falls back to loopback, and every token stamped with the public FQDN fails
21
+ * the `iss` check → `HubJwtError('issuer')` → 401 on every OAuth/MCP reconnect.
22
+ * Bricks team onboarding on any exposed deploy.
23
+ *
24
+ * The durable fix: write `PARACHUTE_HUB_ORIGIN` into `vault/.env` so the daemon
25
+ * boot path picks it up too. Mirrors `auto-wire.ts`'s vault-.env writes
26
+ * (SCRIBE_AUTH_TOKEN / SCRIBE_URL) — hub owns these keys, vault's boot wrapper
27
+ * reads them.
28
+ */
29
+ import { join } from "node:path";
30
+ import { parseEnvFile, removeEnvLine, upsertEnvLine, writeEnvFile } from "./env-file.ts";
31
+ import { HUB_ORIGIN_ENV } from "./hub-origin.ts";
32
+
33
+ /**
34
+ * Loopback origins (`http://127.0.0.1:<port>`, `localhost`, `[::1]`) are the
35
+ * local-dev fallback `deriveHubOrigin` returns when no exposure is active. We
36
+ * never *persist* one — baking a loopback value into `.env` would SHADOW a
37
+ * later exposure on the daemon boot path (which has no other source of truth),
38
+ * recreating the exact `iss` mismatch this fix prevents. Worse than absent.
39
+ */
40
+ export function isLoopbackOrigin(origin: string): boolean {
41
+ try {
42
+ // URL.hostname keeps IPv6 in bracket form (`[::1]`); strip them so the
43
+ // comparison is on the bare address.
44
+ const hostname = new URL(origin).hostname.replace(/^\[|\]$/g, "");
45
+ // `0.0.0.0` is a bind-all wildcard, not a reachable origin — `deriveHubOrigin`
46
+ // never emits it, but `--hub-origin http://0.0.0.0:1939` flows straight
47
+ // through `persistVaultHubOrigin`. Baking it into vault/.env would advertise
48
+ // a non-functional issuer and recreate the iss-mismatch class this guard
49
+ // exists to prevent. Refuse it like any other loopback.
50
+ return (
51
+ hostname === "127.0.0.1" ||
52
+ hostname === "localhost" ||
53
+ hostname === "::1" ||
54
+ hostname === "0.0.0.0"
55
+ );
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ function vaultEnvPath(configDir: string): string {
62
+ return join(configDir, "vault", ".env");
63
+ }
64
+
65
+ /**
66
+ * Upsert `PARACHUTE_HUB_ORIGIN=<origin>` into `vault/.env` when `origin` is a
67
+ * non-loopback public origin. Idempotent — skips the write (and the log) when
68
+ * the value is already current so repeated `start`s don't churn the file.
69
+ * Returns true iff the file was written this call.
70
+ */
71
+ export function persistVaultHubOrigin(
72
+ configDir: string,
73
+ origin: string,
74
+ log: (line: string) => void,
75
+ ): boolean {
76
+ if (isLoopbackOrigin(origin)) return false;
77
+ const path = vaultEnvPath(configDir);
78
+ const parsed = parseEnvFile(path);
79
+ if (parsed.values[HUB_ORIGIN_ENV] === origin) return false;
80
+ writeEnvFile(path, upsertEnvLine(parsed.lines, HUB_ORIGIN_ENV, origin));
81
+ log(` persisted ${HUB_ORIGIN_ENV}=${origin} to ${path} (survives daemon restart)`);
82
+ return true;
83
+ }
84
+
85
+ /**
86
+ * Drop a previously-persisted `PARACHUTE_HUB_ORIGIN` from `vault/.env`. Called
87
+ * on `expose … off`: once exposure is torn down, a local-only hub mints tokens
88
+ * with a loopback `iss`, so a stale public origin left in `.env` would itself
89
+ * cause the mismatch. Removing the line reverts vault to its loopback default
90
+ * (`getHubOrigin`), which matches what the local hub now stamps. No-op (returns
91
+ * false) when the key isn't present. Returns true iff the file was rewritten.
92
+ */
93
+ export function clearVaultHubOrigin(configDir: string, log: (line: string) => void): boolean {
94
+ const path = vaultEnvPath(configDir);
95
+ const parsed = parseEnvFile(path);
96
+ if (parsed.values[HUB_ORIGIN_ENV] === undefined) return false;
97
+ writeEnvFile(path, removeEnvLine(parsed.lines, HUB_ORIGIN_ENV));
98
+ log(` cleared ${HUB_ORIGIN_ENV} from ${path} (exposure torn down)`);
99
+ return true;
100
+ }