@openparachute/hub 0.5.2 → 0.5.9-rc.6

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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +159 -320
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +123 -0
  12. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  13. package/src/__tests__/expose.test.ts +199 -340
  14. package/src/__tests__/hub-server.test.ts +986 -66
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/install.test.ts +50 -31
  18. package/src/__tests__/jwt-sign.test.ts +205 -0
  19. package/src/__tests__/lifecycle.test.ts +97 -2
  20. package/src/__tests__/module-manifest.test.ts +48 -0
  21. package/src/__tests__/notes-serve.test.ts +154 -2
  22. package/src/__tests__/oauth-handlers.test.ts +1000 -3
  23. package/src/__tests__/operator-token.test.ts +379 -3
  24. package/src/__tests__/origin-check.test.ts +220 -0
  25. package/src/__tests__/port-assign.test.ts +41 -52
  26. package/src/__tests__/rate-limit.test.ts +190 -0
  27. package/src/__tests__/services-manifest.test.ts +341 -0
  28. package/src/__tests__/setup.test.ts +12 -9
  29. package/src/__tests__/status.test.ts +372 -0
  30. package/src/__tests__/well-known.test.ts +69 -0
  31. package/src/admin-clients.ts +139 -0
  32. package/src/admin-handlers.ts +63 -260
  33. package/src/admin-host-admin-token.ts +25 -10
  34. package/src/admin-login-ui.ts +256 -0
  35. package/src/admin-vault-admin-token.ts +1 -1
  36. package/src/api-me.ts +124 -0
  37. package/src/api-mint-token.ts +239 -0
  38. package/src/api-revocation-list.ts +59 -0
  39. package/src/api-revoke-token.ts +153 -0
  40. package/src/api-tokens.ts +224 -0
  41. package/src/commands/auth.ts +408 -51
  42. package/src/commands/expose-2fa-warning.ts +82 -0
  43. package/src/commands/expose-cloudflare.ts +27 -0
  44. package/src/commands/expose-public-auto.ts +3 -7
  45. package/src/commands/expose.ts +88 -173
  46. package/src/commands/install.ts +11 -13
  47. package/src/commands/lifecycle.ts +53 -4
  48. package/src/commands/status.ts +99 -8
  49. package/src/csrf.ts +6 -3
  50. package/src/help.ts +13 -7
  51. package/src/hub-db.ts +63 -0
  52. package/src/hub-server.ts +572 -106
  53. package/src/hub.ts +272 -149
  54. package/src/install-source.ts +291 -0
  55. package/src/jwt-sign.ts +265 -5
  56. package/src/module-manifest.ts +48 -10
  57. package/src/notes-serve.ts +70 -9
  58. package/src/oauth-handlers.ts +395 -29
  59. package/src/oauth-ui.ts +188 -0
  60. package/src/operator-token.ts +272 -18
  61. package/src/origin-check.ts +127 -0
  62. package/src/port-assign.ts +28 -35
  63. package/src/rate-limit.ts +166 -0
  64. package/src/scope-explanations.ts +33 -2
  65. package/src/service-spec.ts +58 -13
  66. package/src/services-manifest.ts +62 -3
  67. package/src/sessions.ts +19 -0
  68. package/src/well-known.ts +54 -1
  69. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  70. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/__tests__/admin-config.test.ts +0 -281
  73. package/src/admin-config-ui.ts +0 -534
  74. package/src/admin-config.ts +0 -226
  75. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  76. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
package/src/oauth-ui.ts CHANGED
@@ -97,6 +97,41 @@ export interface ErrorViewProps {
97
97
  status: number;
98
98
  }
99
99
 
100
+ /**
101
+ * Props for the "App not yet approved" view rendered when an unapproved
102
+ * client lands on `/oauth/authorize`. When `session` is true the operator is
103
+ * authenticated to this hub from the browser making the request, so we render
104
+ * an inline approve form (closes #208). When false we fall back to the
105
+ * pre-#208 CLI-only message.
106
+ */
107
+ export interface ApprovePendingViewProps {
108
+ /** Display name to show — falls back to client_id when no name was supplied at DCR. */
109
+ clientName: string;
110
+ clientId: string;
111
+ redirectUris: string[];
112
+ /** Scopes parsed from the original `/oauth/authorize?scope=` query param. */
113
+ requestedScopes: string[];
114
+ /**
115
+ * Vault hint from the original `/oauth/authorize?vault=<name>` query param,
116
+ * passed by Notes' VaultPopover (notes#115) when kicking the OAuth flow for
117
+ * a specific vault. Rendered alongside scopes so the operator can tell
118
+ * which vault they're approving access for on a multi-vault hub (closes
119
+ * #244). Single-vault hubs leave this absent and the section omits.
120
+ */
121
+ requestedVault?: string;
122
+ /**
123
+ * When set, render the inline approve form. The form posts to
124
+ * `/oauth/authorize/approve` with the CSRF token + a `return_to` URL the
125
+ * server will redirect to after the approve commits — the original
126
+ * `/oauth/authorize?...` URL so the OAuth flow re-enters with the now-
127
+ * approved client and lands on the consent screen.
128
+ */
129
+ approveForm?: {
130
+ csrfToken: string;
131
+ returnTo: string;
132
+ };
133
+ }
134
+
100
135
  export function renderLogin(props: LoginViewProps): string {
101
136
  const { params, errorMessage, csrfToken } = props;
102
137
  const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
@@ -204,6 +239,92 @@ function renderVaultPicker(picker: VaultPicker): string {
204
239
  </section>`;
205
240
  }
206
241
 
242
+ /**
243
+ * "App not yet approved" page (#74). When the request carries a valid
244
+ * operator session (#208), render the inline approve form so one click lands
245
+ * the client as `approved` and re-enters the OAuth flow at consent. Without
246
+ * a session, fall back to the original CLI-only message — anyone hitting
247
+ * /oauth/authorize unauthenticated to the hub itself can't be trusted to
248
+ * approve a DCR client from the browser, so they need to drop to a terminal
249
+ * and run `parachute auth approve-client <id>`.
250
+ *
251
+ * The CLI fallback hint is shown in BOTH branches: a button-equipped operator
252
+ * may still want the CLI invocation handy (different machine, scriptable
253
+ * context). The button is the easy path; the CLI is always-available.
254
+ */
255
+ export function renderApprovePending(props: ApprovePendingViewProps): string {
256
+ const { clientName, clientId, redirectUris, requestedScopes, requestedVault, approveForm } =
257
+ props;
258
+ const redirectList = redirectUris.map((u) => `<li><code>${escapeHtml(u)}</code></li>`).join("");
259
+ const scopeRows =
260
+ requestedScopes.length === 0
261
+ ? `<li class="scope scope-empty">No scopes requested — the app gets a session token only.</li>`
262
+ : requestedScopes.map(renderScopeRow).join("\n");
263
+ // Vault hint (closes #244): Notes' VaultPopover (notes#115) passes
264
+ // `vault=<name>` on `/oauth/authorize` for per-vault grants. Surface it
265
+ // alongside scopes so a multi-vault operator can tell which vault they're
266
+ // approving for. Missing on single-vault hubs / pre-vault-popover clients —
267
+ // section omits when absent.
268
+ const vaultRow = requestedVault
269
+ ? `
270
+ <p class="approve-meta-row">
271
+ <span class="approve-meta-label">vault</span>
272
+ <code class="approve-meta-value">${escapeHtml(requestedVault)}</code>
273
+ </p>`
274
+ : "";
275
+ const formSection = approveForm
276
+ ? `
277
+ <form method="POST" action="/oauth/authorize/approve" class="auth-form approve-form">
278
+ ${renderCsrfHiddenInput(approveForm.csrfToken)}
279
+ <input type="hidden" name="client_id" value="${escapeHtml(clientId)}" />
280
+ <input type="hidden" name="return_to" value="${escapeHtml(approveForm.returnTo)}" />
281
+ <button type="submit" class="btn btn-primary">Approve and continue</button>
282
+ </form>
283
+ <p class="approve-cli-hint">
284
+ Or run <code>parachute auth approve-client ${escapeHtml(clientId)}</code> from a terminal.
285
+ </p>`
286
+ : `
287
+ <p class="approve-cli-hint">
288
+ Ask the operator to run <code>parachute auth approve-client ${escapeHtml(clientId)}</code>
289
+ from a terminal, then try again.
290
+ </p>`;
291
+ const body = `
292
+ <div class="card">
293
+ <div class="card-header">
294
+ <div class="brand">
295
+ <span class="brand-mark">⌬</span>
296
+ <span class="brand-name">Parachute</span>
297
+ </div>
298
+ <h1>App not yet approved</h1>
299
+ <p class="subtitle">
300
+ ${escapeHtml(clientName)} is registered with this hub but hasn't been approved yet.
301
+ Review the details below before approving.
302
+ </p>
303
+ </div>
304
+ <section class="approve-meta">
305
+ <h2 class="scopes-title">Application</h2>
306
+ <p class="approve-meta-row">
307
+ <span class="approve-meta-label">name</span>
308
+ <code class="approve-meta-value">${escapeHtml(clientName)}</code>
309
+ </p>
310
+ <p class="approve-meta-row">
311
+ <span class="approve-meta-label">client_id</span>
312
+ <code class="approve-meta-value">${escapeHtml(clientId)}</code>
313
+ </p>${vaultRow}
314
+ <div class="approve-meta-row approve-meta-row-block">
315
+ <span class="approve-meta-label">redirect_uris</span>
316
+ <ul class="approve-redirect-list">${redirectList}</ul>
317
+ </div>
318
+ </section>
319
+ <section class="scopes">
320
+ <h2 class="scopes-title">Permissions requested</h2>
321
+ <ul class="scope-list">${scopeRows}</ul>
322
+ </section>
323
+ ${formSection}
324
+ </div>`;
325
+ return baseDocument("App not yet approved", body);
326
+ }
327
+
207
328
  export function renderError(props: ErrorViewProps): string {
208
329
  const body = `
209
330
  <div class="card">
@@ -542,6 +663,73 @@ const STYLES = `
542
663
  .vault-picker-empty .picker-help { color: ${PALETTE.danger}; }
543
664
  .vault-picker-empty .picker-help code { color: ${PALETTE.fg}; }
544
665
 
666
+ .approve-meta {
667
+ margin: 0 0 1.25rem;
668
+ padding: 0.75rem 0.85rem;
669
+ border: 1px solid ${PALETTE.borderLight};
670
+ border-radius: 6px;
671
+ background: ${PALETTE.bgSoft};
672
+ }
673
+ .approve-meta .scopes-title { margin-bottom: 0.5rem; }
674
+ .approve-meta-row {
675
+ margin: 0 0 0.4rem;
676
+ display: flex;
677
+ gap: 0.5rem;
678
+ align-items: baseline;
679
+ flex-wrap: wrap;
680
+ }
681
+ .approve-meta-row:last-child { margin-bottom: 0; }
682
+ .approve-meta-row-block { flex-direction: column; gap: 0.25rem; }
683
+ .approve-meta-label {
684
+ text-transform: uppercase;
685
+ letter-spacing: 0.05em;
686
+ font-size: 0.7rem;
687
+ color: ${PALETTE.fgDim};
688
+ }
689
+ .approve-meta-value {
690
+ font-family: ${FONT_MONO};
691
+ font-size: 0.82rem;
692
+ background: ${PALETTE.cardBg};
693
+ padding: 0.1rem 0.4rem;
694
+ border-radius: 4px;
695
+ color: ${PALETTE.fg};
696
+ word-break: break-all;
697
+ }
698
+ .approve-redirect-list {
699
+ list-style: none;
700
+ margin: 0;
701
+ padding: 0;
702
+ display: flex;
703
+ flex-direction: column;
704
+ gap: 0.25rem;
705
+ }
706
+ .approve-redirect-list li code {
707
+ font-family: ${FONT_MONO};
708
+ font-size: 0.82rem;
709
+ background: ${PALETTE.cardBg};
710
+ padding: 0.1rem 0.4rem;
711
+ border-radius: 4px;
712
+ color: ${PALETTE.fg};
713
+ word-break: break-all;
714
+ }
715
+ .approve-form { gap: 0; }
716
+ .approve-cli-hint {
717
+ margin-top: 1rem;
718
+ padding-top: 0.85rem;
719
+ border-top: 1px solid ${PALETTE.borderLight};
720
+ color: ${PALETTE.fgMuted};
721
+ font-size: 0.85rem;
722
+ }
723
+ .approve-cli-hint code {
724
+ font-family: ${FONT_MONO};
725
+ font-size: 0.8rem;
726
+ background: ${PALETTE.bgSoft};
727
+ padding: 0.1rem 0.4rem;
728
+ border-radius: 4px;
729
+ color: ${PALETTE.fg};
730
+ word-break: break-all;
731
+ }
732
+
545
733
  .badge {
546
734
  display: inline-block;
547
735
  font-size: 0.7rem;
@@ -12,30 +12,116 @@
12
12
  * Browser apps follow the OAuth flow and never touch this file. Service
13
13
  * accounts (cron jobs, oncall scripts) read it; that's the whole point.
14
14
  *
15
- * Rotation: cheap. `parachute auth rotate-operator` mints a fresh token
16
- * and overwrites the file. The previous token is *not* revoked at the
17
- * issuer the hub doesn't track operator-token jtis so a leaked file
18
- * stays valid until its 1-year TTL elapses. Treat operator.token like an
19
- * SSH private key.
15
+ * Lifetime: 90 days by default (was 365d through 0.5.7). The opportunistic
16
+ * auto-rotation helper `useOperatorTokenWithAutoRotate` re-mints any
17
+ * within-7d-of-expiry token in-place, so an operator who runs the CLI at
18
+ * least weekly never sees an expiry surprise. Fully expired tokens fail
19
+ * with an explicit re-auth message — auto-rotating from a dead token would
20
+ * defeat the lifetime cap (security: forces a manual re-auth touch).
21
+ *
22
+ * Operator-token jtis are tracked in the hub `tokens` registry as of
23
+ * hub#212 Phase 1 (created_via='operator_mint'); per-jti revocation is
24
+ * enforced via validateAccessToken's row.revokedAt check. A leaked file
25
+ * still stays valid until either its TTL elapses or the operator
26
+ * explicitly revokes the jti — treat operator.token like an SSH private
27
+ * key.
20
28
  */
21
29
  import type { Database } from "bun:sqlite";
22
30
  import { promises as fs } from "node:fs";
23
31
  import { join } from "node:path";
24
32
  import { configDir } from "./config.ts";
25
- import { signAccessToken } from "./jwt-sign.ts";
33
+ import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
26
34
 
27
35
  export const OPERATOR_TOKEN_FILENAME = "operator.token";
28
- export const OPERATOR_TOKEN_TTL_SECONDS = 365 * 24 * 60 * 60;
36
+ /** Default operator-token lifetime 90 days, was 365d through 0.5.7 (#213). */
37
+ export const OPERATOR_TOKEN_TTL_SECONDS = 90 * 24 * 60 * 60;
38
+ /**
39
+ * Auto-rotation threshold. When a CLI flow validates an operator token whose
40
+ * remaining lifetime is less than this, it silently re-mints with the same
41
+ * scope-set + a fresh full TTL. 7 days picked so a once-a-week operator
42
+ * never sees expiry; longer would let stale tokens accumulate, shorter
43
+ * would re-mint too often.
44
+ */
45
+ export const OPERATOR_TOKEN_AUTO_ROTATE_THRESHOLD_SECONDS = 7 * 24 * 60 * 60;
29
46
  export const OPERATOR_TOKEN_AUDIENCE = "operator";
30
47
  export const OPERATOR_TOKEN_CLIENT_ID = "parachute-hub";
31
- export const OPERATOR_TOKEN_SCOPES = [
32
- "hub:admin",
33
- "parachute:host:admin",
34
- "vault:admin",
35
- "scribe:admin",
36
- "channel:send",
48
+
49
+ /**
50
+ * Named scope-sets a `parachute auth rotate-operator` invocation can choose
51
+ * via `--scope-set`. Each set encodes the minimum scopes required for that
52
+ * operator-flow's API surface; `admin` is the back-compat superset and
53
+ * stays the default.
54
+ *
55
+ * Per-command gating rolls out incrementally:
56
+ * - Auth surfaces (hub#221 `revoke-token`, hub#222 `mint-token`) gate on
57
+ * `parachute:host:auth` — both `admin` and `auth` scope-sets carry it.
58
+ * - Other CLI commands (`install`, `start`, `expose`, etc.) still accept
59
+ * any token with `hub:admin` and don't yet check the narrower scopes;
60
+ * a future follow-up wires per-command enforcement so a `start`-set
61
+ * token can only lifecycle-manage, not install. Until then,
62
+ * `--scope-set` is a tool the cautious operator can opt into without
63
+ * breaking anyone.
64
+ *
65
+ * The fine-grained `parachute:host:install/start/expose/auth/vault` scopes
66
+ * are operator-only (non-requestable via public OAuth), like
67
+ * `parachute:host:admin` — registered in `scope-explanations.ts`.
68
+ */
69
+ export type OperatorScopeSet = "install" | "start" | "expose" | "auth" | "vault" | "admin";
70
+
71
+ export const OPERATOR_TOKEN_SCOPE_SET_NAMES: readonly OperatorScopeSet[] = [
72
+ "install",
73
+ "start",
74
+ "expose",
75
+ "auth",
76
+ "vault",
77
+ "admin",
37
78
  ];
38
79
 
80
+ /**
81
+ * Scopes embedded for each named set. `admin` preserves the pre-#213
82
+ * `OPERATOR_TOKEN_SCOPES` set verbatim plus the new fine-grained host
83
+ * scopes (which `admin` is a superset of by definition).
84
+ */
85
+ export const OPERATOR_TOKEN_SCOPE_SETS: Readonly<Record<OperatorScopeSet, readonly string[]>> = {
86
+ install: ["parachute:host:install", "vault:read"],
87
+ start: ["parachute:host:start"],
88
+ expose: ["parachute:host:expose"],
89
+ auth: ["parachute:host:auth"],
90
+ vault: ["parachute:host:vault"],
91
+ admin: [
92
+ "hub:admin",
93
+ "parachute:host:admin",
94
+ "parachute:host:install",
95
+ "parachute:host:start",
96
+ "parachute:host:expose",
97
+ "parachute:host:auth",
98
+ "parachute:host:vault",
99
+ "vault:admin",
100
+ "scribe:admin",
101
+ "channel:send",
102
+ ],
103
+ };
104
+
105
+ /**
106
+ * Pre-#213 export: the broad "admin" scope-set as a flat array. Kept for
107
+ * back-compat with callers (e.g. existing tests) that imported the constant
108
+ * directly. New callers should use `OPERATOR_TOKEN_SCOPE_SETS.admin`.
109
+ */
110
+ export const OPERATOR_TOKEN_SCOPES = OPERATOR_TOKEN_SCOPE_SETS.admin;
111
+
112
+ /** Custom JWT claim that records which scope-set this operator token was minted under. */
113
+ export const OPERATOR_TOKEN_SCOPE_SET_CLAIM = "pa_scope_set";
114
+
115
+ /** Default scope-set when none is specified. Preserves pre-#213 behavior. */
116
+ export const OPERATOR_TOKEN_DEFAULT_SCOPE_SET: OperatorScopeSet = "admin";
117
+
118
+ export function isOperatorScopeSet(value: unknown): value is OperatorScopeSet {
119
+ return (
120
+ typeof value === "string" &&
121
+ (OPERATOR_TOKEN_SCOPE_SET_NAMES as readonly string[]).includes(value)
122
+ );
123
+ }
124
+
39
125
  export function operatorTokenPath(dir: string = configDir()): string {
40
126
  return join(dir, OPERATOR_TOKEN_FILENAME);
41
127
  }
@@ -54,23 +140,47 @@ export interface MintOperatorTokenOpts {
54
140
  jti?: string;
55
141
  /** Override the audience claim. Defaults to "operator". */
56
142
  audience?: string;
143
+ /** Which named scope-set to mint under. Defaults to "admin" (pre-#213 behavior). */
144
+ scopeSet?: OperatorScopeSet;
145
+ /** Override the lifetime. Tests pin this; production uses the default. */
146
+ ttlSeconds?: number;
57
147
  }
58
148
 
59
149
  export async function mintOperatorToken(
60
150
  db: Database,
61
151
  userId: string,
62
152
  opts: MintOperatorTokenOpts,
63
- ): Promise<{ token: string; jti: string; expiresAt: string }> {
64
- return signAccessToken(db, {
153
+ ): Promise<{ token: string; jti: string; expiresAt: string; scopeSet: OperatorScopeSet }> {
154
+ const scopeSet = opts.scopeSet ?? OPERATOR_TOKEN_DEFAULT_SCOPE_SET;
155
+ const scopes = [...OPERATOR_TOKEN_SCOPE_SETS[scopeSet]];
156
+ const minted = await signAccessToken(db, {
65
157
  sub: userId,
66
- scopes: OPERATOR_TOKEN_SCOPES,
158
+ scopes,
67
159
  audience: opts.audience ?? OPERATOR_TOKEN_AUDIENCE,
68
160
  clientId: OPERATOR_TOKEN_CLIENT_ID,
69
161
  issuer: opts.issuer,
70
- ttlSeconds: OPERATOR_TOKEN_TTL_SECONDS,
162
+ ttlSeconds: opts.ttlSeconds ?? OPERATOR_TOKEN_TTL_SECONDS,
163
+ extraClaims: { [OPERATOR_TOKEN_SCOPE_SET_CLAIM]: scopeSet },
71
164
  ...(opts.jti !== undefined ? { jti: opts.jti } : {}),
72
165
  ...(opts.now !== undefined ? { now: opts.now } : {}),
73
166
  });
167
+ // Register every operator-mint with the unified token registry (hub#212
168
+ // Phase 1). Per design: operator-mint rows have user_id NULL; the
169
+ // subject column carries the canonical "operator" identity string.
170
+ // (Storing user_id here would require an FK-valid users row, which the
171
+ // operator-mint path doesn't always have access to in test fixtures —
172
+ // and conceptually the operator is a role, not a hub user.) Powers the
173
+ // revocation list endpoint.
174
+ recordTokenMint(db, {
175
+ jti: minted.jti,
176
+ createdVia: "operator_mint",
177
+ subject: "operator",
178
+ clientId: OPERATOR_TOKEN_CLIENT_ID,
179
+ scopes,
180
+ expiresAt: minted.expiresAt,
181
+ ...(opts.now !== undefined ? { now: opts.now } : {}),
182
+ });
183
+ return { ...minted, scopeSet };
74
184
  }
75
185
 
76
186
  /**
@@ -86,6 +196,10 @@ export async function writeOperatorTokenFile(
86
196
  const path = operatorTokenPath(dir);
87
197
  const tmp = `${path}.tmp`;
88
198
  await fs.writeFile(tmp, `${token}\n`, { mode: 0o600 });
199
+ // Defense-in-depth: if the file already existed with looser permissions,
200
+ // some platforms (Linux, macOS) preserve the prior inode's mode rather
201
+ // than honoring the create-mode hint on rename. Force 0600 explicitly.
202
+ await fs.chmod(tmp, 0o600);
89
203
  await fs.rename(tmp, path);
90
204
  return path;
91
205
  }
@@ -94,11 +208,19 @@ export async function writeOperatorTokenFile(
94
208
  * Reads the operator token file, trims trailing whitespace. Returns null
95
209
  * if the file doesn't exist (caller decides whether that's an error). Any
96
210
  * other read error propagates.
211
+ *
212
+ * On read, checks file permissions. If the file is group- or world-readable
213
+ * (mode bits 0o077 set), logs a warning but does NOT fail the read — a
214
+ * read-only failure here would lock operators out of every CLI command,
215
+ * with no in-CLI way to recover. The warning + remediation hint
216
+ * (`chmod 0600 <path>`) lets the operator self-correct without losing
217
+ * access. New writes via `writeOperatorTokenFile` are always 0600.
97
218
  */
98
219
  export async function readOperatorTokenFile(dir: string = configDir()): Promise<string | null> {
99
220
  const path = operatorTokenPath(dir);
100
221
  try {
101
222
  const buf = await fs.readFile(path, "utf8");
223
+ await warnIfWorldReadable(path);
102
224
  const trimmed = buf.trim();
103
225
  return trimmed.length > 0 ? trimmed : null;
104
226
  } catch (err) {
@@ -107,16 +229,35 @@ export async function readOperatorTokenFile(dir: string = configDir()): Promise<
107
229
  }
108
230
  }
109
231
 
232
+ async function warnIfWorldReadable(path: string): Promise<void> {
233
+ try {
234
+ const stat = await fs.stat(path);
235
+ const looseBits = stat.mode & 0o077;
236
+ if (looseBits !== 0) {
237
+ const mode = (stat.mode & 0o777).toString(8).padStart(4, "0");
238
+ console.error(
239
+ `parachute: operator token file at ${path} has mode ${mode} (group/other can read it). Run \`chmod 0600 ${path}\` to lock it down.`,
240
+ );
241
+ }
242
+ } catch {
243
+ // If stat fails (file vanished between read and stat, or platform
244
+ // doesn't expose mode bits), skip the warning — the read already
245
+ // succeeded, and this is defense-in-depth, not a hard gate.
246
+ }
247
+ }
248
+
110
249
  export interface IssueOperatorTokenResult {
111
250
  token: string;
112
251
  jti: string;
113
252
  expiresAt: string;
114
253
  path: string;
254
+ scopeSet: OperatorScopeSet;
115
255
  }
116
256
 
117
257
  /**
118
258
  * Mint + write in one call. Used by `parachute auth set-password` (after
119
- * password set) and `parachute auth rotate-operator`.
259
+ * password set), `parachute auth rotate-operator`, and the auto-rotation
260
+ * path inside `useOperatorTokenWithAutoRotate`.
120
261
  */
121
262
  export async function issueOperatorToken(
122
263
  db: Database,
@@ -127,3 +268,116 @@ export async function issueOperatorToken(
127
268
  const path = await writeOperatorTokenFile(minted.token, opts.dir);
128
269
  return { ...minted, path };
129
270
  }
271
+
272
+ export class OperatorTokenExpiredError extends Error {
273
+ override name = "OperatorTokenExpiredError";
274
+ }
275
+
276
+ export interface UseOperatorTokenOpts {
277
+ /** Hub origin used as `iss` validator. Required. */
278
+ issuer: string;
279
+ /** configDir override (where operator.token lives). Defaults to `configDir()`. */
280
+ configDir?: string;
281
+ /**
282
+ * Override the rotation clock. Tests pin this; production uses
283
+ * `() => new Date()`.
284
+ */
285
+ now?: () => Date;
286
+ }
287
+
288
+ export interface UsedOperatorToken {
289
+ /** The operator token plaintext to present as bearer. After auto-rotation, this is the freshly-minted token. */
290
+ token: string;
291
+ /** Validated payload of `token` (post-rotation if a rotation occurred). */
292
+ payload: Awaited<ReturnType<typeof validateAccessToken>>["payload"];
293
+ /** Set when this call rotated the on-disk token. The new path on disk. */
294
+ rotated?: { path: string; scopeSet: OperatorScopeSet; expiresAt: string };
295
+ /** True if the on-disk token was within the auto-rotation threshold (informational). */
296
+ refreshed: boolean;
297
+ }
298
+
299
+ /**
300
+ * The canonical "use the operator token in a CLI flow" helper. Reads
301
+ * `~/.parachute/operator.token`, validates against `db` + `issuer`, and:
302
+ *
303
+ * - If the token has fully expired: throws `OperatorTokenExpiredError`
304
+ * with an actionable message. Does NOT auto-rotate from a dead token —
305
+ * auto-rotating an expired token would defeat the lifetime cap.
306
+ * - If the remaining lifetime is below the auto-rotate threshold (7d):
307
+ * re-mints under the same scope-set, writes back to disk, and returns
308
+ * the new token. Operator never sees an expiry surprise as long as
309
+ * they exercise the CLI at least weekly.
310
+ * - Otherwise: returns the original token + payload.
311
+ *
312
+ * Callers receive the (possibly fresh) token to present onward. The
313
+ * scope-set is preserved across rotations via the `pa_scope_set` claim;
314
+ * tokens minted before #213 don't carry the claim and are treated as
315
+ * `admin` (back-compat).
316
+ */
317
+ export async function useOperatorTokenWithAutoRotate(
318
+ db: Database,
319
+ opts: UseOperatorTokenOpts,
320
+ ): Promise<UsedOperatorToken | null> {
321
+ const dir = opts.configDir ?? configDir();
322
+ const token = await readOperatorTokenFile(dir);
323
+ if (!token) return null;
324
+ const now = opts.now ?? (() => new Date());
325
+
326
+ // Validation failures (signature mismatch, wrong issuer, missing kid,
327
+ // expired-by-jose) bubble out for the caller to render the right message.
328
+ const validated = await validateAccessToken(db, token, opts.issuer);
329
+ const { payload } = validated;
330
+
331
+ const exp = typeof payload.exp === "number" ? payload.exp : 0;
332
+ const nowSec = Math.floor(now().getTime() / 1000);
333
+ const remaining = exp - nowSec;
334
+
335
+ // jose's verify will reject expired tokens before we get here, so this
336
+ // branch is defensive; callers that catch validateAccessToken errors and
337
+ // re-call this with a hand-rolled payload would land here.
338
+ if (remaining <= 0) {
339
+ throw new OperatorTokenExpiredError(
340
+ "your operator token has expired; run `parachute auth rotate-operator` to re-mint",
341
+ );
342
+ }
343
+
344
+ if (remaining > OPERATOR_TOKEN_AUTO_ROTATE_THRESHOLD_SECONDS) {
345
+ return { token, payload, refreshed: false };
346
+ }
347
+
348
+ // Within rotation window — but only auto-rotate if this is genuinely an
349
+ // operator token. The audience check is the privilege-escalation guard:
350
+ // an arbitrary scope-narrow JWT (aud: "scribe", "vault", …) hand-stashed
351
+ // at ~/.parachute/operator.token must NOT be silently upgraded to a full
352
+ // operator token by the hub. Legitimate operator-tokens minted via
353
+ // `set-password` / `rotate-operator` carry `aud: "operator"`.
354
+ if (payload.aud !== OPERATOR_TOKEN_AUDIENCE) {
355
+ return { token, payload, refreshed: false };
356
+ }
357
+
358
+ // Re-mint preserving scope-set.
359
+ const sub = typeof payload.sub === "string" ? payload.sub : null;
360
+ if (!sub) {
361
+ // No sub claim — can't safely auto-rotate (don't know who the token
362
+ // belongs to). Return as-is; the caller will likely surface this as an
363
+ // invalid-token error downstream.
364
+ return { token, payload, refreshed: false };
365
+ }
366
+ const claimedSet = payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM];
367
+ const scopeSet: OperatorScopeSet = isOperatorScopeSet(claimedSet)
368
+ ? claimedSet
369
+ : OPERATOR_TOKEN_DEFAULT_SCOPE_SET;
370
+ const issued = await issueOperatorToken(db, sub, {
371
+ dir,
372
+ issuer: opts.issuer,
373
+ scopeSet,
374
+ now: opts.now,
375
+ });
376
+ const reValidated = await validateAccessToken(db, issued.token, opts.issuer);
377
+ return {
378
+ token: issued.token,
379
+ payload: reValidated.payload,
380
+ rotated: { path: issued.path, scopeSet: issued.scopeSet, expiresAt: issued.expiresAt },
381
+ refreshed: true,
382
+ };
383
+ }