@openparachute/vault 0.4.9-rc.4 → 0.4.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.
@@ -0,0 +1,415 @@
1
+ /**
2
+ * GitHub OAuth Device Flow client + supporting API calls.
3
+ *
4
+ * Why Device Flow (not Web Flow): self-hosted vault origins are
5
+ * unpredictable (localhost:1940, random Tailscale FQDN, custom domain). Web
6
+ * Flow needs a pre-registered callback URL per OAuth app; Device Flow needs
7
+ * only a public `client_id` and the operator authorizes by typing a code at
8
+ * github.com/login/device from any device. Same UX as `gh auth login`.
9
+ *
10
+ * Spec: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow
11
+ *
12
+ * All HTTP calls accept an injectable `fetch` so tests can mock the wire
13
+ * without spawning a real GitHub round-trip. Production wiring uses the
14
+ * platform `fetch` (Bun's native).
15
+ *
16
+ * **GITHUB_CLIENT_ID setup (REQUIRED before this works in production):**
17
+ *
18
+ * 1. Visit https://github.com/settings/developers
19
+ * 2. "OAuth Apps" → "New OAuth App"
20
+ * 3. Application name: "Parachute Vault" (or operator-chosen)
21
+ * Homepage URL: https://parachute.computer
22
+ * Authorization callback URL: https://parachute.computer/oauth/github
23
+ * (callback URL is required by GitHub's form but unused in Device Flow)
24
+ * 4. After creating: open the app's settings page
25
+ * 5. Tick the "Enable Device Flow" checkbox
26
+ * 6. Copy the Client ID (looks like `Iv1.abc123...` or `Ov23li...`)
27
+ * 7. Set the `GITHUB_CLIENT_ID` constant below to that value
28
+ *
29
+ * The placeholder ships with this PR. Production builds are gated on a real
30
+ * client_id; the PR body flags Aaron as the action owner.
31
+ */
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Client ID — REPLACE BEFORE TAGGING A RELEASE.
35
+ //
36
+ // This is the public OAuth app client_id. No secret is needed for Device
37
+ // Flow (the operator's typed code is the proof-of-presence factor). Safe to
38
+ // commit + ship in client builds. But: tied to ONE registered GitHub OAuth
39
+ // App, which Aaron owns under the Parachute org / his account. Set this
40
+ // after running the setup checklist above.
41
+ //
42
+ // TODO(aaron): replace with the real client_id from the registered OAuth
43
+ // app. Until then, the device-flow endpoints return a clear-error response
44
+ // instead of attempting GitHub's API with an invalid id (which surfaces an
45
+ // opaque "Not Found" that's hard to debug).
46
+ // ---------------------------------------------------------------------------
47
+
48
+ export const GITHUB_CLIENT_ID_PLACEHOLDER =
49
+ "Iv1.PLACEHOLDER_REPLACE_ME_BEFORE_RELEASE" as const;
50
+
51
+ /**
52
+ * The active client id at runtime. Resolved from the env (preferred — lets
53
+ * operators override per-deploy) or the constant above (for tests +
54
+ * defaults). Defaults to the placeholder; the route handlers check for the
55
+ * placeholder and return an actionable error before hitting GitHub.
56
+ */
57
+ export function getGithubClientId(): string {
58
+ return process.env.PARACHUTE_GITHUB_CLIENT_ID || GITHUB_CLIENT_ID_PLACEHOLDER;
59
+ }
60
+
61
+ /** Returns true when no real client id has been configured. */
62
+ export function isPlaceholderClientId(clientId: string): boolean {
63
+ return clientId === GITHUB_CLIENT_ID_PLACEHOLDER || clientId.includes("PLACEHOLDER");
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Types
68
+ // ---------------------------------------------------------------------------
69
+
70
+ export interface DeviceCodeResponse {
71
+ device_code: string;
72
+ user_code: string;
73
+ verification_uri: string;
74
+ expires_in: number;
75
+ /** Seconds the client should wait between polls. */
76
+ interval: number;
77
+ }
78
+
79
+ /** Discriminated union of poll outcomes. */
80
+ export type TokenPollResult =
81
+ | { state: "pending" }
82
+ | { state: "slow_down"; interval: number }
83
+ | { state: "expired" }
84
+ | { state: "denied" }
85
+ | {
86
+ state: "granted";
87
+ access_token: string;
88
+ scope: string;
89
+ token_type: string;
90
+ };
91
+
92
+ export interface GitHubUser {
93
+ login: string;
94
+ id: number;
95
+ name: string | null;
96
+ avatar_url?: string;
97
+ }
98
+
99
+ export interface GitHubRepoInfo {
100
+ owner: string;
101
+ name: string;
102
+ full_name: string;
103
+ private: boolean;
104
+ html_url: string;
105
+ description: string | null;
106
+ /** ISO timestamp. Used by the SPA for "last updated" sort/display. */
107
+ updated_at: string;
108
+ /** HTTPS clone URL (no auth). The mirror gets the authed shape applied
109
+ * separately via `applyToGitRemote`. */
110
+ clone_url: string;
111
+ }
112
+
113
+ export interface ListReposResult {
114
+ repos: GitHubRepoInfo[];
115
+ /** True when the operator has more than `maxPages * per_page` repos and
116
+ * we stopped paginating. Signals the UI to recommend the manual-URL
117
+ * paste or a search filter. */
118
+ truncated: boolean;
119
+ }
120
+
121
+ /** Minimal fetch-like surface — injectable for tests. */
122
+ export type FetchLike = (
123
+ input: string,
124
+ init?: { method?: string; headers?: Record<string, string>; body?: string },
125
+ ) => Promise<{
126
+ ok: boolean;
127
+ status: number;
128
+ text: () => Promise<string>;
129
+ json: () => Promise<unknown>;
130
+ }>;
131
+
132
+ function defaultFetch(): FetchLike {
133
+ return globalThis.fetch as FetchLike;
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Device Flow endpoints
138
+ // ---------------------------------------------------------------------------
139
+
140
+ /**
141
+ * Start a device-flow authorization. POSTs to GitHub's `/login/device/code`
142
+ * with the public client_id + the `repo` scope (the minimum we need to push
143
+ * to the operator's private repos).
144
+ *
145
+ * Throws on transport or shape error — the route handler catches + returns
146
+ * a 502. Successful return is the four-tuple GitHub spec calls for
147
+ * (device_code, user_code, verification_uri, expires_in, interval).
148
+ */
149
+ export async function requestDeviceCode(
150
+ clientId: string,
151
+ fetchImpl: FetchLike = defaultFetch(),
152
+ ): Promise<DeviceCodeResponse> {
153
+ const res = await fetchImpl("https://github.com/login/device/code", {
154
+ method: "POST",
155
+ headers: {
156
+ accept: "application/json",
157
+ "content-type": "application/x-www-form-urlencoded",
158
+ },
159
+ body: new URLSearchParams({
160
+ client_id: clientId,
161
+ // `repo` is the broad-private-repo scope. Read-only push isn't a
162
+ // thing on GitHub; if we want to push, we need write access to repo
163
+ // contents, which `repo` includes. The narrower scope `public_repo`
164
+ // wouldn't cover private repos — most operator vaults are private.
165
+ scope: "repo",
166
+ }).toString(),
167
+ });
168
+ if (!res.ok) {
169
+ const body = await res.text();
170
+ throw new Error(
171
+ `GitHub device-code request failed (${res.status}): ${body.slice(0, 200)}`,
172
+ );
173
+ }
174
+ const parsed = (await res.json()) as Partial<DeviceCodeResponse>;
175
+ if (
176
+ typeof parsed.device_code !== "string" ||
177
+ typeof parsed.user_code !== "string" ||
178
+ typeof parsed.verification_uri !== "string" ||
179
+ typeof parsed.expires_in !== "number" ||
180
+ typeof parsed.interval !== "number"
181
+ ) {
182
+ throw new Error(
183
+ `GitHub device-code response missing required fields: ${JSON.stringify(parsed).slice(0, 200)}`,
184
+ );
185
+ }
186
+ return parsed as DeviceCodeResponse;
187
+ }
188
+
189
+ /**
190
+ * Poll GitHub's `/login/oauth/access_token` for a granted token. Returns a
191
+ * discriminated union the route handler can branch on.
192
+ *
193
+ * GitHub returns a 200 with an `error` field for the in-flight states
194
+ * (`authorization_pending`, `slow_down`, etc.); we map those to our `state`
195
+ * field. A true HTTP error (5xx) throws.
196
+ */
197
+ export async function pollForToken(
198
+ clientId: string,
199
+ deviceCode: string,
200
+ fetchImpl: FetchLike = defaultFetch(),
201
+ ): Promise<TokenPollResult> {
202
+ const res = await fetchImpl("https://github.com/login/oauth/access_token", {
203
+ method: "POST",
204
+ headers: {
205
+ accept: "application/json",
206
+ "content-type": "application/x-www-form-urlencoded",
207
+ },
208
+ body: new URLSearchParams({
209
+ client_id: clientId,
210
+ device_code: deviceCode,
211
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
212
+ }).toString(),
213
+ });
214
+ if (!res.ok) {
215
+ const body = await res.text();
216
+ throw new Error(
217
+ `GitHub access_token poll failed (${res.status}): ${body.slice(0, 200)}`,
218
+ );
219
+ }
220
+ const parsed = (await res.json()) as Record<string, unknown>;
221
+
222
+ // Success: { access_token, scope, token_type }
223
+ if (typeof parsed.access_token === "string") {
224
+ return {
225
+ state: "granted",
226
+ access_token: parsed.access_token,
227
+ scope: typeof parsed.scope === "string" ? parsed.scope : "",
228
+ token_type: typeof parsed.token_type === "string" ? parsed.token_type : "bearer",
229
+ };
230
+ }
231
+
232
+ // In-flight / failure states: { error, error_description, interval? }
233
+ const error = typeof parsed.error === "string" ? parsed.error : null;
234
+ if (error === "authorization_pending") return { state: "pending" };
235
+ if (error === "slow_down") {
236
+ const interval = typeof parsed.interval === "number" ? parsed.interval : 5;
237
+ return { state: "slow_down", interval };
238
+ }
239
+ if (error === "expired_token") return { state: "expired" };
240
+ if (error === "access_denied") return { state: "denied" };
241
+ // Unknown error — treat as denied so the UI surfaces a clear failure
242
+ // rather than hanging on pending. The actual GitHub error string is
243
+ // not surfaced (it'd leak via logs); the route's response carries a
244
+ // generic "denied" + the message via console.warn.
245
+ console.warn(
246
+ `[github-device-flow] unexpected error from access_token poll: ${error ?? JSON.stringify(parsed).slice(0, 100)}`,
247
+ );
248
+ return { state: "denied" };
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // User + repo APIs (used after token granted)
253
+ // ---------------------------------------------------------------------------
254
+
255
+ /**
256
+ * Fetch the authenticated user's profile. Used immediately after a
257
+ * `granted` token to populate `user_login` + `user_id` in the stored
258
+ * credential.
259
+ */
260
+ export async function fetchUser(
261
+ token: string,
262
+ fetchImpl: FetchLike = defaultFetch(),
263
+ ): Promise<GitHubUser> {
264
+ const res = await fetchImpl("https://api.github.com/user", {
265
+ headers: {
266
+ accept: "application/vnd.github+json",
267
+ authorization: `token ${token}`,
268
+ "X-GitHub-Api-Version": "2022-11-28",
269
+ },
270
+ });
271
+ if (!res.ok) {
272
+ const body = await res.text();
273
+ throw new Error(
274
+ `GitHub /user fetch failed (${res.status}): ${body.slice(0, 200)}`,
275
+ );
276
+ }
277
+ const parsed = (await res.json()) as Partial<GitHubUser>;
278
+ if (typeof parsed.login !== "string" || typeof parsed.id !== "number") {
279
+ throw new Error(`GitHub /user response missing login or id`);
280
+ }
281
+ return {
282
+ login: parsed.login,
283
+ id: parsed.id,
284
+ name: typeof parsed.name === "string" ? parsed.name : null,
285
+ avatar_url: typeof parsed.avatar_url === "string" ? parsed.avatar_url : undefined,
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Paginated list of repos the authenticated user owns. Sorted by most-
291
+ * recently-updated so the repo the operator probably wants is near the top.
292
+ * Truncates after `maxPages * perPage` repos (default 3 * 100 = 300) — most
293
+ * operators have far fewer, the truncation signals the UI to prompt for a
294
+ * search filter.
295
+ */
296
+ export async function listRepos(
297
+ token: string,
298
+ opts: { maxPages?: number; perPage?: number } = {},
299
+ fetchImpl: FetchLike = defaultFetch(),
300
+ ): Promise<ListReposResult> {
301
+ const perPage = opts.perPage ?? 100;
302
+ const maxPages = opts.maxPages ?? 3;
303
+ const repos: GitHubRepoInfo[] = [];
304
+ let truncated = false;
305
+ for (let page = 1; page <= maxPages; page++) {
306
+ const res = await fetchImpl(
307
+ `https://api.github.com/user/repos?type=owner&sort=updated&per_page=${perPage}&page=${page}`,
308
+ {
309
+ headers: {
310
+ accept: "application/vnd.github+json",
311
+ authorization: `token ${token}`,
312
+ "X-GitHub-Api-Version": "2022-11-28",
313
+ },
314
+ },
315
+ );
316
+ if (!res.ok) {
317
+ const body = await res.text();
318
+ throw new Error(
319
+ `GitHub /user/repos fetch failed (${res.status}, page ${page}): ${body.slice(0, 200)}`,
320
+ );
321
+ }
322
+ const items = (await res.json()) as Array<{
323
+ name: string;
324
+ full_name: string;
325
+ private: boolean;
326
+ html_url: string;
327
+ description: string | null;
328
+ updated_at: string;
329
+ clone_url: string;
330
+ owner: { login: string };
331
+ }>;
332
+ for (const item of items) {
333
+ repos.push({
334
+ owner: item.owner.login,
335
+ name: item.name,
336
+ full_name: item.full_name,
337
+ private: item.private,
338
+ html_url: item.html_url,
339
+ description: item.description,
340
+ updated_at: item.updated_at,
341
+ clone_url: item.clone_url,
342
+ });
343
+ }
344
+ // No more pages.
345
+ if (items.length < perPage) {
346
+ return { repos, truncated: false };
347
+ }
348
+ // Page filled to perPage — there might be more. If we're at the cap,
349
+ // mark as truncated and return.
350
+ if (page === maxPages) {
351
+ truncated = true;
352
+ }
353
+ }
354
+ return { repos, truncated };
355
+ }
356
+
357
+ /**
358
+ * Create a new repo on the authenticated user's account. Defaults to private
359
+ * because the operator's vault is more likely sensitive than public. The
360
+ * repo gets initialized empty (no README) so the first `git push` from the
361
+ * mirror lands the operator's vault as commit 1.
362
+ */
363
+ export async function createRepo(
364
+ token: string,
365
+ opts: { name: string; description?: string; private?: boolean },
366
+ fetchImpl: FetchLike = defaultFetch(),
367
+ ): Promise<GitHubRepoInfo> {
368
+ const res = await fetchImpl("https://api.github.com/user/repos", {
369
+ method: "POST",
370
+ headers: {
371
+ accept: "application/vnd.github+json",
372
+ authorization: `token ${token}`,
373
+ "X-GitHub-Api-Version": "2022-11-28",
374
+ "content-type": "application/json",
375
+ },
376
+ body: JSON.stringify({
377
+ name: opts.name,
378
+ description: opts.description ?? "Parachute Vault mirror",
379
+ private: opts.private ?? true,
380
+ auto_init: false,
381
+ }),
382
+ });
383
+ if (!res.ok) {
384
+ const body = await res.text();
385
+ let parsed: { message?: string } = {};
386
+ try {
387
+ parsed = JSON.parse(body) as { message?: string };
388
+ } catch {
389
+ // not JSON
390
+ }
391
+ throw new Error(
392
+ `GitHub /user/repos create failed (${res.status}): ${parsed.message ?? body.slice(0, 200)}`,
393
+ );
394
+ }
395
+ const item = (await res.json()) as {
396
+ name: string;
397
+ full_name: string;
398
+ private: boolean;
399
+ html_url: string;
400
+ description: string | null;
401
+ updated_at: string;
402
+ clone_url: string;
403
+ owner: { login: string };
404
+ };
405
+ return {
406
+ owner: item.owner.login,
407
+ name: item.name,
408
+ full_name: item.full_name,
409
+ private: item.private,
410
+ html_url: item.html_url,
411
+ description: item.description,
412
+ updated_at: item.updated_at,
413
+ clone_url: item.clone_url,
414
+ };
415
+ }
@@ -12,6 +12,9 @@ import os from "node:os";
12
12
  import path from "node:path";
13
13
 
14
14
  import {
15
+ DEFAULT_SAFETY_NET_SECONDS,
16
+ MAX_SAFETY_NET_SECONDS,
17
+ MIN_SAFETY_NET_SECONDS,
15
18
  defaultMirrorConfig,
16
19
  parseMirrorConfig,
17
20
  resolveMirrorPath,
@@ -40,11 +43,14 @@ describe("defaultMirrorConfig", () => {
40
43
  expect(d.enabled).toBe(false);
41
44
  expect(d.location).toBe("internal");
42
45
  expect(d.external_path).toBeNull();
43
- expect(d.watch).toBe(false);
46
+ // Post event-driven shift: sync_mode replaces watch. "events" is the
47
+ // new default — when an operator flips enabled on, hooks subscribe
48
+ // automatically.
49
+ expect(d.sync_mode).toBe("events");
44
50
  expect(d.auto_commit).toBe(true);
45
51
  expect(d.auto_push).toBe(false);
46
52
  expect(d.commit_template).toContain("{{date}}");
47
- expect(d.interval_seconds).toBe(5);
53
+ expect(d.safety_net_seconds).toBe(DEFAULT_SAFETY_NET_SECONDS);
48
54
  });
49
55
  });
50
56
 
@@ -58,41 +64,68 @@ describe("parseMirrorConfig", () => {
58
64
  expect(parseMirrorConfig("")).toBeUndefined();
59
65
  });
60
66
 
61
- test("parses a fully-specified mirror block", () => {
67
+ test("parses a fully-specified mirror block (post-event-driven shape)", () => {
62
68
  const yaml = [
63
69
  "port: 1940",
64
70
  "mirror:",
65
71
  " enabled: true",
66
72
  " location: external",
67
73
  " external_path: /home/aaron/mirrors/gitcoin",
68
- " watch: true",
74
+ " sync_mode: events",
69
75
  " auto_commit: true",
70
76
  " auto_push: true",
71
77
  ' commit_template: "vault: {{notes_changed}} note{{plural}}"',
72
- " interval_seconds: 10",
78
+ " safety_net_seconds: 3600",
73
79
  ].join("\n");
74
80
  const m = parseMirrorConfig(yaml);
75
81
  expect(m).toEqual({
76
82
  enabled: true,
77
83
  location: "external",
78
84
  external_path: "/home/aaron/mirrors/gitcoin",
79
- watch: true,
85
+ sync_mode: "events",
80
86
  auto_commit: true,
81
87
  auto_push: true,
82
88
  commit_template: "vault: {{notes_changed}} note{{plural}}",
83
- interval_seconds: 10,
89
+ safety_net_seconds: 3600,
84
90
  });
85
91
  });
86
92
 
87
93
  test("partial mirror block fills missing fields from defaults", () => {
88
- const yaml = "mirror:\n enabled: true\n watch: true\n";
94
+ const yaml = "mirror:\n enabled: true\n sync_mode: manual\n";
89
95
  const m = parseMirrorConfig(yaml)!;
90
96
  expect(m.enabled).toBe(true);
91
- expect(m.watch).toBe(true);
97
+ expect(m.sync_mode).toBe("manual");
92
98
  expect(m.location).toBe("internal");
93
99
  expect(m.auto_commit).toBe(true);
94
100
  });
95
101
 
102
+ test("legacy `watch: true` translates to sync_mode: events", () => {
103
+ const m = parseMirrorConfig("mirror:\n enabled: true\n watch: true\n")!;
104
+ expect(m.sync_mode).toBe("events");
105
+ });
106
+
107
+ test("legacy `watch: false` translates to sync_mode: manual", () => {
108
+ const m = parseMirrorConfig("mirror:\n enabled: true\n watch: false\n")!;
109
+ expect(m.sync_mode).toBe("manual");
110
+ });
111
+
112
+ test("explicit sync_mode wins over legacy watch", () => {
113
+ const yaml = "mirror:\n enabled: true\n watch: true\n sync_mode: manual\n";
114
+ const m = parseMirrorConfig(yaml)!;
115
+ expect(m.sync_mode).toBe("manual");
116
+ });
117
+
118
+ test("legacy `interval_seconds: 5` clamps up to MIN_SAFETY_NET_SECONDS", () => {
119
+ const m = parseMirrorConfig("mirror:\n enabled: true\n interval_seconds: 5\n")!;
120
+ expect(m.safety_net_seconds).toBe(MIN_SAFETY_NET_SECONDS);
121
+ });
122
+
123
+ test("explicit safety_net_seconds wins over legacy interval_seconds", () => {
124
+ const yaml = "mirror:\n enabled: true\n interval_seconds: 5\n safety_net_seconds: 1800\n";
125
+ const m = parseMirrorConfig(yaml)!;
126
+ expect(m.safety_net_seconds).toBe(1800);
127
+ });
128
+
96
129
  test("external_path: null is interpreted as null", () => {
97
130
  const m = parseMirrorConfig(
98
131
  "mirror:\n enabled: true\n external_path: null\n",
@@ -120,11 +153,11 @@ describe("serializeMirrorConfig", () => {
120
153
  enabled: true,
121
154
  location: "external" as const,
122
155
  external_path: "/home/aaron/team-brain",
123
- watch: true,
156
+ sync_mode: "events" as const,
124
157
  auto_commit: true,
125
158
  auto_push: false,
126
159
  commit_template: "export: {{date}} ({{notes_changed}} note{{plural}})",
127
- interval_seconds: 5,
160
+ safety_net_seconds: 3600,
128
161
  };
129
162
  const yaml = serializeMirrorConfig(original).join("\n") + "\n";
130
163
  const parsed = parseMirrorConfig(yaml);
@@ -251,10 +284,88 @@ describe("validateMirrorConfigShape", () => {
251
284
  if (!r.ok) expect(r.field).toBe("enabled");
252
285
  });
253
286
 
254
- test("rejects non-integer interval_seconds", () => {
255
- const r = validateMirrorConfigShape({ interval_seconds: 0.5 });
287
+ test("rejects non-integer safety_net_seconds", () => {
288
+ const r = validateMirrorConfigShape({ safety_net_seconds: 0.5 });
289
+ expect(r.ok).toBe(false);
290
+ if (!r.ok) expect(r.field).toBe("safety_net_seconds");
291
+ });
292
+
293
+ test("rejects safety_net_seconds below MIN", () => {
294
+ const r = validateMirrorConfigShape({ safety_net_seconds: MIN_SAFETY_NET_SECONDS - 1 });
256
295
  expect(r.ok).toBe(false);
257
- if (!r.ok) expect(r.field).toBe("interval_seconds");
296
+ if (!r.ok) expect(r.field).toBe("safety_net_seconds");
297
+ });
298
+
299
+ test("rejects safety_net_seconds above MAX", () => {
300
+ const r = validateMirrorConfigShape({ safety_net_seconds: MAX_SAFETY_NET_SECONDS + 1 });
301
+ expect(r.ok).toBe(false);
302
+ if (!r.ok) expect(r.field).toBe("safety_net_seconds");
303
+ });
304
+
305
+ test("legacy interval_seconds field clamps + migrates to safety_net_seconds", () => {
306
+ // Hand-edited config supplies the old field; we still accept it but
307
+ // route it through the safety-net clamp range.
308
+ const r = validateMirrorConfigShape({ interval_seconds: 5 });
309
+ expect(r.ok).toBe(true);
310
+ if (r.ok) expect(r.config.safety_net_seconds).toBe(MIN_SAFETY_NET_SECONDS);
311
+ });
312
+
313
+ test("rejects unknown sync_mode", () => {
314
+ const r = validateMirrorConfigShape({ sync_mode: "interval" });
315
+ expect(r.ok).toBe(false);
316
+ if (!r.ok) expect(r.field).toBe("sync_mode");
317
+ });
318
+
319
+ test("accepts sync_mode events / manual", () => {
320
+ expect((validateMirrorConfigShape({ sync_mode: "events" }) as { config: { sync_mode: string } }).config.sync_mode).toBe("events");
321
+ expect((validateMirrorConfigShape({ sync_mode: "manual" }) as { config: { sync_mode: string } }).config.sync_mode).toBe("manual");
322
+ });
323
+
324
+ test("legacy watch: true translates to sync_mode: events", () => {
325
+ const r = validateMirrorConfigShape({ watch: true });
326
+ expect(r.ok).toBe(true);
327
+ if (r.ok) expect(r.config.sync_mode).toBe("events");
328
+ });
329
+
330
+ test("legacy watch: false translates to sync_mode: manual", () => {
331
+ const r = validateMirrorConfigShape({ watch: false });
332
+ expect(r.ok).toBe(true);
333
+ if (r.ok) expect(r.config.sync_mode).toBe("manual");
334
+ });
335
+
336
+ test("rejects auto_push + internal location (validation rather than silent-fail)", () => {
337
+ // Pre-event-driven shape silently let push fail at runtime for
338
+ // internal mirrors. Now we reject the combination at config time so
339
+ // the operator sees the issue immediately.
340
+ const r = validateMirrorConfigShape({
341
+ enabled: true,
342
+ location: "internal",
343
+ auto_push: true,
344
+ });
345
+ expect(r.ok).toBe(false);
346
+ if (!r.ok) expect(r.field).toBe("auto_push");
347
+ });
348
+
349
+ test("auto_push + external location is fine", () => {
350
+ const r = validateMirrorConfigShape({
351
+ enabled: true,
352
+ location: "external",
353
+ external_path: "/tmp/foo",
354
+ auto_push: true,
355
+ });
356
+ expect(r.ok).toBe(true);
357
+ });
358
+
359
+ test("auto_push + disabled never errors", () => {
360
+ // Cross-field rule gates on `enabled`. A disabled config with stale
361
+ // auto_push: true + internal is the upgrade-path shape; operators
362
+ // shouldn't have to clear the field to disable.
363
+ const r = validateMirrorConfigShape({
364
+ enabled: false,
365
+ location: "internal",
366
+ auto_push: true,
367
+ });
368
+ expect(r.ok).toBe(true);
258
369
  });
259
370
 
260
371
  test("rejects empty commit_template", () => {