@openparachute/vault 0.4.8 → 0.4.9-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 (40) hide show
  1. package/core/src/hooks.test.ts +320 -1
  2. package/core/src/hooks.ts +243 -38
  3. package/core/src/mcp.ts +35 -0
  4. package/core/src/portable-md.test.ts +252 -1
  5. package/core/src/portable-md.ts +370 -2
  6. package/core/src/schema.ts +51 -2
  7. package/core/src/store.ts +68 -2
  8. package/package.json +1 -1
  9. package/src/auth.ts +29 -1
  10. package/src/auto-transcribe.test.ts +7 -2
  11. package/src/auto-transcribe.ts +6 -2
  12. package/src/export-watch.test.ts +74 -0
  13. package/src/export-watch.ts +108 -7
  14. package/src/github-device-flow.test.ts +404 -0
  15. package/src/github-device-flow.ts +415 -0
  16. package/src/mcp-http.ts +24 -36
  17. package/src/mcp-tools.ts +286 -2
  18. package/src/mirror-config.test.ts +184 -14
  19. package/src/mirror-config.ts +220 -24
  20. package/src/mirror-credentials.test.ts +450 -0
  21. package/src/mirror-credentials.ts +577 -0
  22. package/src/mirror-deps.ts +42 -1
  23. package/src/mirror-import.test.ts +550 -0
  24. package/src/mirror-import.ts +484 -0
  25. package/src/mirror-manager.test.ts +423 -12
  26. package/src/mirror-manager.ts +579 -62
  27. package/src/mirror-routes.test.ts +966 -10
  28. package/src/mirror-routes.ts +1096 -5
  29. package/src/module-config.ts +11 -5
  30. package/src/routing.test.ts +92 -1
  31. package/src/routing.ts +165 -1
  32. package/src/server.ts +21 -8
  33. package/src/token-store.ts +158 -5
  34. package/src/transcription-worker.ts +9 -4
  35. package/src/triggers.ts +16 -3
  36. package/src/vault.test.ts +380 -1
  37. package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
  38. package/web/ui/dist/assets/index-DE18QJMx.js +60 -0
  39. package/web/ui/dist/index.html +2 -2
  40. package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
package/src/mcp-tools.ts CHANGED
@@ -14,14 +14,21 @@ import {
14
14
  } from "../core/src/vault-projection.ts";
15
15
  import { readVaultConfig, writeVaultConfig } from "./config.ts";
16
16
  import { getVaultStore } from "./vault-store.ts";
17
- import { hasScopeForVault } from "./scopes.ts";
17
+ import { hasScopeForVault, parseScopes, validateMintedScopes, hasScope, SCOPE_WRITE, SCOPE_ADMIN } from "./scopes.ts";
18
18
  import type { AuthResult } from "./auth.ts";
19
19
  import {
20
20
  expandTokenTagScope,
21
21
  noteWithinTagScope,
22
22
  tagsWithinScope,
23
23
  } from "./tag-scope.ts";
24
- import { findTokensReferencingTag } from "./token-store.ts";
24
+ import {
25
+ findTokensReferencingTag,
26
+ generateToken,
27
+ createToken,
28
+ listMcpMintedTokens,
29
+ softRevokeMcpToken,
30
+ type TokenPermission,
31
+ } from "./token-store.ts";
25
32
 
26
33
  /**
27
34
  * Filter a vault projection to entries an in-scope tag contributes to.
@@ -110,6 +117,12 @@ export function generateScopedMcpTools(vaultName: string, auth?: AuthResult): Mc
110
117
  applyTagDependencyGuards(tools, vaultName);
111
118
  applyTagScopeWrappers(tools, vaultName, auth);
112
119
 
120
+ // manage-token is server-only (needs token-store + auth context), so it
121
+ // lives here rather than in core. Always appended to the surface; the
122
+ // `requiredVerb: "admin"` filter in mcp-http.ts hides it from non-admin
123
+ // callers. See vault#376.
124
+ tools.push(buildManageTokenTool(vaultName, auth));
125
+
113
126
  return tools;
114
127
  }
115
128
 
@@ -404,3 +417,274 @@ function overrideVaultInfo(
404
417
  return result;
405
418
  };
406
419
  }
420
+
421
+ // ---------------------------------------------------------------------------
422
+ // manage-token (vault#376) — single MCP tool with mint/revoke/list actions
423
+ // ---------------------------------------------------------------------------
424
+
425
+ /**
426
+ * TTL bounds for `manage-token` action=mint, in seconds. Short by design:
427
+ * the design doc (vault#376) calls the tool out as the "AI mints a token
428
+ * for one-shot scripted work, then revokes immediately" surface. A long
429
+ * TTL would defeat the safety story — if revoke fails (network blip,
430
+ * model error), the cap is the backstop. Operators wanting long-lived
431
+ * tokens still use the REST /vault/<name>/tokens endpoint.
432
+ */
433
+ const MANAGE_TOKEN_DEFAULT_TTL_SECONDS = 900; // 15 minutes
434
+ const MANAGE_TOKEN_MAX_TTL_SECONDS = 3600; // 1 hour
435
+
436
+ function permissionForScopes(scopes: string[]): TokenPermission {
437
+ return hasScope(scopes, SCOPE_WRITE) || hasScope(scopes, SCOPE_ADMIN) ? "full" : "read";
438
+ }
439
+
440
+ /**
441
+ * Build the manage-token MCP tool, wired to the calling session's auth.
442
+ *
443
+ * Closure-captured context:
444
+ * - `vaultName`: every mint pins `vault_name` to this; cross-vault mints
445
+ * are rejected by `validateMintedScopes` (it refuses any
446
+ * `vault:<other>:<verb>` scope).
447
+ * - `auth.scopes`: defense-in-depth subset check on mint. The outer
448
+ * filter already required vault:admin to see the tool, but a hand-
449
+ * crafted JSON-RPC `tools/call` of `manage-token` from a non-admin
450
+ * session would bypass the visibility filter — `validateMintedScopes`
451
+ * plus the `hasScopeForVault(auth.scopes, vaultName, "admin")` guard
452
+ * below catch that case.
453
+ * - `auth.caller_jti`: stamped as `parent_jti` on each mint; list+revoke
454
+ * scope to this jti so each MCP session sees only its own mints.
455
+ * When NULL (legacy / env-var operator / hub JWT without jti), mints
456
+ * still succeed but list/revoke return empty — the operator hits the
457
+ * CLI / REST surface instead for revocation in that path.
458
+ *
459
+ * The execute function is async (token mint touches the store + DB) and
460
+ * returns a discriminated-union response shape: `{action, …}` with `action`
461
+ * matching the requested action. The MCP HTTP layer serializes the result
462
+ * via `JSON.stringify`, so caller-side parsing keys off the action field.
463
+ */
464
+ function buildManageTokenTool(vaultName: string, auth: AuthResult | undefined): McpToolDef {
465
+ return {
466
+ name: "manage-token",
467
+ requiredVerb: "admin",
468
+ description:
469
+ "Mint, revoke, or list short-TTL vault tokens within this MCP session. " +
470
+ "Designed for one-shot AI-driven workflows: mint a narrow token, run a " +
471
+ "script with it, revoke immediately. Token lifetime defaults to 15 min " +
472
+ "(max 1 hour). Mints are pinned to this vault and to the caller's scope " +
473
+ "subset — you cannot escalate. List + revoke are scoped to tokens this " +
474
+ "session minted; CLI/REST-minted tokens are not surfaced here.\n\n" +
475
+ "Actions (discriminator: `action`):\n" +
476
+ "- `mint` — { scope: string|string[], ttl_seconds?: number, description?: string } → { action: \"mint\", token, jti, expires_at }\n" +
477
+ "- `revoke` — { jti: string } → { action: \"revoke\", ok: boolean }\n" +
478
+ "- `list` — (no inputs) → { action: \"list\", tokens: [...] }",
479
+ inputSchema: {
480
+ type: "object",
481
+ properties: {
482
+ action: {
483
+ type: "string",
484
+ enum: ["mint", "revoke", "list"],
485
+ description: "Which action to perform. Required.",
486
+ },
487
+ scope: {
488
+ oneOf: [
489
+ { type: "string" },
490
+ { type: "array", items: { type: "string" } },
491
+ ],
492
+ description:
493
+ "(action=mint) Scope to grant. String like \"vault:write\" or array. Must be a subset of the caller's scope; cross-vault scopes are rejected.",
494
+ },
495
+ ttl_seconds: {
496
+ type: "number",
497
+ description: `(action=mint) Token lifetime in seconds. Default ${MANAGE_TOKEN_DEFAULT_TTL_SECONDS} (15 min), max ${MANAGE_TOKEN_MAX_TTL_SECONDS} (1 hour). Values outside (0, ${MANAGE_TOKEN_MAX_TTL_SECONDS}] are rejected.`,
498
+ },
499
+ description: {
500
+ type: "string",
501
+ description: "(action=mint, optional) Free-text label surfaced in the token list + audit trail.",
502
+ },
503
+ jti: {
504
+ type: "string",
505
+ description: "(action=revoke) The jti (e.g. `t_abc123…`) returned by a prior mint. Revoke is idempotent — second revoke also returns ok=true.",
506
+ },
507
+ },
508
+ required: ["action"],
509
+ },
510
+ execute: async (params) => {
511
+ const action = params.action;
512
+
513
+ // Defense-in-depth: the outer filter (mcp-http.ts visibleTools)
514
+ // already requires vault:admin for this vault to see manage-token,
515
+ // so reaching execute means the gate passed. A hand-crafted
516
+ // tools/call bypassing list would still hit the dispatch verb-check
517
+ // in handleScopedMcp. The block below is a third belt-and-suspenders
518
+ // check so a refactor of either layer can't lose the invariant
519
+ // silently.
520
+ if (!auth || !hasScopeForVault(auth.scopes, vaultName, "admin")) {
521
+ return {
522
+ action,
523
+ error: "Forbidden",
524
+ message: `manage-token requires the 'vault:admin' scope (or 'vault:${vaultName}:admin'). Granted: ${auth?.scopes.join(" ") || "(none)"}.`,
525
+ };
526
+ }
527
+
528
+ if (action === "mint") return await mintAction(params, vaultName, auth);
529
+ if (action === "revoke") return revokeAction(params, vaultName, auth);
530
+ if (action === "list") return listAction(vaultName, auth);
531
+
532
+ return {
533
+ error: "invalid_request",
534
+ message: `manage-token: unknown action "${String(action)}" — expected "mint" | "revoke" | "list".`,
535
+ };
536
+ },
537
+ };
538
+ }
539
+
540
+ async function mintAction(
541
+ params: Record<string, unknown>,
542
+ vaultName: string,
543
+ auth: AuthResult,
544
+ ): Promise<Record<string, unknown>> {
545
+ // Scope parsing: accept string or string[]. Empty/missing is rejected
546
+ // explicitly (no implicit "full scope" default — manage-token always
547
+ // narrows). The validateMintedScopes call then enforces:
548
+ // - shape (recognized vault scope)
549
+ // - vault-pin (cross-vault rejected)
550
+ // - subset of caller's scope on this vault.
551
+ let requested: string[];
552
+ if (typeof params.scope === "string") {
553
+ requested = parseScopes(params.scope);
554
+ } else if (Array.isArray(params.scope)) {
555
+ requested = params.scope.filter((s): s is string => typeof s === "string" && s.length > 0);
556
+ } else {
557
+ return {
558
+ action: "mint",
559
+ error: "invalid_request",
560
+ message: "manage-token mint: `scope` is required (string or string[]).",
561
+ };
562
+ }
563
+ if (requested.length === 0) {
564
+ return {
565
+ action: "mint",
566
+ error: "invalid_request",
567
+ message: "manage-token mint: at least one scope required.",
568
+ };
569
+ }
570
+
571
+ const validation = validateMintedScopes(requested, vaultName, auth.scopes);
572
+ if (!validation.ok) {
573
+ return {
574
+ action: "mint",
575
+ error: "forbidden",
576
+ message: "manage-token mint: scope rejected (must be a subset of the caller's scope on this vault).",
577
+ rejected: validation.rejected,
578
+ };
579
+ }
580
+
581
+ // TTL bounds. Default 900 (15 min); explicit values must satisfy
582
+ // `0 < ttl <= MANAGE_TOKEN_MAX_TTL_SECONDS`. Zero, negative, NaN, and
583
+ // beyond-max all reject — the cap is the safety backstop if revoke fails,
584
+ // so it must be strict.
585
+ let ttl = MANAGE_TOKEN_DEFAULT_TTL_SECONDS;
586
+ if (params.ttl_seconds !== undefined && params.ttl_seconds !== null) {
587
+ if (typeof params.ttl_seconds !== "number" || !Number.isFinite(params.ttl_seconds)) {
588
+ return {
589
+ action: "mint",
590
+ error: "invalid_request",
591
+ message: "manage-token mint: ttl_seconds must be a finite number.",
592
+ };
593
+ }
594
+ if (params.ttl_seconds <= 0 || params.ttl_seconds > MANAGE_TOKEN_MAX_TTL_SECONDS) {
595
+ return {
596
+ action: "mint",
597
+ error: "invalid_request",
598
+ message: `manage-token mint: ttl_seconds must be in (0, ${MANAGE_TOKEN_MAX_TTL_SECONDS}]; got ${params.ttl_seconds}.`,
599
+ };
600
+ }
601
+ ttl = params.ttl_seconds;
602
+ }
603
+ const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
604
+
605
+ const description = typeof params.description === "string" && params.description.length > 0
606
+ ? params.description
607
+ : null;
608
+ const label = description ?? `mcp-mint (parent=${auth.caller_jti ?? "unknown"})`;
609
+
610
+ const store = getVaultStore(vaultName);
611
+ const { fullToken } = generateToken();
612
+ const created = createToken(store.db, fullToken, {
613
+ label,
614
+ permission: permissionForScopes(requested),
615
+ scopes: requested,
616
+ // Tag scoping: inherit the caller's allowlist verbatim. We don't expose
617
+ // a `tags` param on manage-token yet — the design doc keeps the v1
618
+ // surface minimal. When the caller is tag-scoped, the minted token
619
+ // carries the same allowlist (no narrowing, no widening); when the
620
+ // caller is unscoped, the mint is unscoped. Future widening of the
621
+ // surface should re-use tokens-routes.ts' validation path so the rules
622
+ // stay in lockstep.
623
+ scoped_tags: auth.scoped_tags,
624
+ vault_name: vaultName,
625
+ expires_at: expiresAt,
626
+ created_via: "mcp_mint",
627
+ parent_jti: auth.caller_jti,
628
+ });
629
+
630
+ return {
631
+ action: "mint",
632
+ token: fullToken,
633
+ jti: `t_${created.token_hash.slice(7, 19)}`,
634
+ expires_at: expiresAt,
635
+ scopes: requested,
636
+ scoped_tags: auth.scoped_tags,
637
+ vault_name: vaultName,
638
+ };
639
+ }
640
+
641
+ function revokeAction(
642
+ params: Record<string, unknown>,
643
+ vaultName: string,
644
+ auth: AuthResult,
645
+ ): Record<string, unknown> {
646
+ if (typeof params.jti !== "string" || params.jti.length === 0) {
647
+ return {
648
+ action: "revoke",
649
+ ok: false,
650
+ error: "invalid_request",
651
+ message: "manage-token revoke: `jti` is required (string).",
652
+ };
653
+ }
654
+ // Session-pin: revoke is restricted to tokens this MCP session minted.
655
+ // When auth.caller_jti is null (no stable session id — env-var operator,
656
+ // legacy YAML key, hub JWT without jti), there are no MCP-minted tokens
657
+ // attributable to this session, so revoke returns not_found.
658
+ if (!auth.caller_jti) {
659
+ return {
660
+ action: "revoke",
661
+ ok: false,
662
+ error: "not_found",
663
+ message: "manage-token revoke: this session has no stable id; revoke via the CLI or REST surface.",
664
+ };
665
+ }
666
+ const store = getVaultStore(vaultName);
667
+ const result = softRevokeMcpToken(store.db, params.jti, auth.caller_jti, vaultName);
668
+ if (!result.ok) {
669
+ // Idempotency: not-found returns ok=true so the AI's "mint → run →
670
+ // revoke" loop doesn't surface a confusing failure when a network
671
+ // blip causes a duplicate revoke call. The spec calls this out
672
+ // explicitly (vault#376). The "already minted by another session"
673
+ // case also lands here; we don't differentiate (no information leak
674
+ // about other sessions' jti space).
675
+ return { action: "revoke", ok: true, note: "no matching token in this session" };
676
+ }
677
+ return { action: "revoke", ok: true, already_revoked: result.already_revoked };
678
+ }
679
+
680
+ function listAction(vaultName: string, auth: AuthResult): Record<string, unknown> {
681
+ if (!auth.caller_jti) {
682
+ // No session id → no attributable mints. Return empty list rather
683
+ // than erroring, so callers can branch on tokens.length without
684
+ // exception handling.
685
+ return { action: "list", tokens: [] };
686
+ }
687
+ const store = getVaultStore(vaultName);
688
+ const tokens = listMcpMintedTokens(store.db, auth.caller_jti, vaultName);
689
+ return { action: "list", tokens };
690
+ }
@@ -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,147 @@ 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 WHEN no credentials are configured", () => {
337
+ // Pre-credentials shape: auto_push + internal was rejected outright
338
+ // (internal mirror = no remote = push would silently fail). Once
339
+ // credentials are wired (PAT or GitHub OAuth), the credential save
340
+ // path sets `origin` on the internal repo too — so push IS
341
+ // meaningful. We keep the rejection only on the no-credentials path,
342
+ // with a clear error pointing the operator at the credential flow.
343
+ const r = validateMirrorConfigShape(
344
+ {
345
+ enabled: true,
346
+ location: "internal",
347
+ auto_push: true,
348
+ },
349
+ { readCredentials: () => null },
350
+ );
351
+ expect(r.ok).toBe(false);
352
+ if (!r.ok) {
353
+ expect(r.field).toBe("auto_push");
354
+ expect(r.error).toContain("credentials");
355
+ }
356
+ });
357
+
358
+ test("auto_push + internal IS accepted when PAT credentials are configured", () => {
359
+ // The three-stacking-gaps bug Aaron hit: History preset (internal
360
+ // location) + PAT saved → expected pushes to fire. validation was
361
+ // the first blocker. Now the combination passes when credentials
362
+ // are present.
363
+ const r = validateMirrorConfigShape(
364
+ {
365
+ enabled: true,
366
+ location: "internal",
367
+ auto_push: true,
368
+ },
369
+ {
370
+ readCredentials: () => ({
371
+ active_method: "pat",
372
+ github_oauth: null,
373
+ pat: {
374
+ token: "ghp_xxxxxxxxxxxxxxxx",
375
+ remote_url: "https://x-access-token:ghp_xxxxxxxxxxxxxxxx@github.com/a/b.git",
376
+ label: "test",
377
+ },
378
+ }),
379
+ },
380
+ );
381
+ expect(r.ok).toBe(true);
382
+ });
383
+
384
+ test("auto_push + internal IS accepted when github_oauth credentials are configured", () => {
385
+ const r = validateMirrorConfigShape(
386
+ {
387
+ enabled: true,
388
+ location: "internal",
389
+ auto_push: true,
390
+ },
391
+ {
392
+ readCredentials: () => ({
393
+ active_method: "github_oauth",
394
+ github_oauth: {
395
+ access_token: "gho_xxxxxxxxxxxx",
396
+ scope: "repo",
397
+ authorized_at: "2026-05-28T03:14:15.000Z",
398
+ user_login: "aaron",
399
+ user_id: 1,
400
+ },
401
+ pat: null,
402
+ }),
403
+ },
404
+ );
405
+ expect(r.ok).toBe(true);
406
+ });
407
+
408
+ test("auto_push + external location is fine", () => {
409
+ const r = validateMirrorConfigShape({
410
+ enabled: true,
411
+ location: "external",
412
+ external_path: "/tmp/foo",
413
+ auto_push: true,
414
+ });
415
+ expect(r.ok).toBe(true);
416
+ });
417
+
418
+ test("auto_push + disabled never errors", () => {
419
+ // Cross-field rule gates on `enabled`. A disabled config with stale
420
+ // auto_push: true + internal is the upgrade-path shape; operators
421
+ // shouldn't have to clear the field to disable.
422
+ const r = validateMirrorConfigShape({
423
+ enabled: false,
424
+ location: "internal",
425
+ auto_push: true,
426
+ });
427
+ expect(r.ok).toBe(true);
258
428
  });
259
429
 
260
430
  test("rejects empty commit_template", () => {