@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.
- package/core/src/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/mcp.ts +35 -0
- package/core/src/portable-md.test.ts +252 -1
- package/core/src/portable-md.ts +370 -2
- package/core/src/schema.ts +51 -2
- package/core/src/store.ts +68 -2
- package/package.json +1 -1
- package/src/auth.ts +29 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- package/src/export-watch.test.ts +74 -0
- package/src/export-watch.ts +108 -7
- package/src/github-device-flow.test.ts +404 -0
- package/src/github-device-flow.ts +415 -0
- package/src/mcp-http.ts +24 -36
- package/src/mcp-tools.ts +286 -2
- package/src/mirror-config.test.ts +184 -14
- package/src/mirror-config.ts +220 -24
- package/src/mirror-credentials.test.ts +450 -0
- package/src/mirror-credentials.ts +577 -0
- package/src/mirror-deps.ts +42 -1
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +484 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +579 -62
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1096 -5
- package/src/module-config.ts +11 -5
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +165 -1
- package/src/server.ts +21 -8
- package/src/token-store.ts +158 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +380 -1
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/assets/index-DE18QJMx.js +60 -0
- package/web/ui/dist/index.html +2 -2
- 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 {
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
74
|
+
" sync_mode: events",
|
|
69
75
|
" auto_commit: true",
|
|
70
76
|
" auto_push: true",
|
|
71
77
|
' commit_template: "vault: {{notes_changed}} note{{plural}}"',
|
|
72
|
-
"
|
|
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
|
-
|
|
85
|
+
sync_mode: "events",
|
|
80
86
|
auto_commit: true,
|
|
81
87
|
auto_push: true,
|
|
82
88
|
commit_template: "vault: {{notes_changed}} note{{plural}}",
|
|
83
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
255
|
-
const r = validateMirrorConfigShape({
|
|
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("
|
|
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", () => {
|