feat(E1b.1): AutoSyncController no-clobber guard via reused /get + ccHash

First story of the E1b controller-hardening epic. Wires the E1b-alt HTML
ccHash into AutoSyncController.process as a Foundry-side divergence guard:
before the PUT, compare ccHash(liveEntry) to the note's foundry.ccHash
baseline; if Foundry drifted since the last sync, ABORT the PUT (fail-safe —
never overwrite a Foundry-side edit).

- src/push.ts: prePushGuard?(liveEntry)=>{abort,reason?} on PushDeps, called
  right after relay.getEntry (the SAME /get pushNote already makes — no extra
  round-trip), before any side effect (image upload/backup/PUT). On abort,
  returns an aborted PushOutcome (no PUT, no backup). PushOutcome gains
  aborted/abortReason/liveEntry (liveEntry exposed so E1b.2 can re-baseline
  ccHash with no second /get).
- src/server.ts: runPushBody passes a prePushGuard that computes ccHash on
  the reused live entry and compares to foundry.ccHash. Absent ccHash (legacy)
  proceeds (one-time migration; E1b.2 writes the post-push baseline).
  Unreadable Foundry side (CcHashError) aborts fail-safe. Feature flag
  AUTOSYNC_FOUNDRY_GUARD (env, default true; controller field for test override;
  off → body-only, documented unsafe).
- tests/e1b1-noclobber.test.ts: 7 tests with a mock relay (real pushNote) —
  clean push PUTs; Foundry-side drift → NO PUT; legacy note proceeds; unreadable
  Foundry side → NO PUT; guard works with sync-state.json ABSENT (no E4 dep);
  flag on/off. Live SM-2 verification stays gated on the operator's headless
  session.

tsc clean; 119 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-22 23:24:23 +00:00
parent 5d96bf1267
commit 348ab30f03
3 changed files with 269 additions and 3 deletions

View File

@@ -44,6 +44,12 @@ export interface PushDeps {
* whether a portrait is present. */
skipImageUpload?: boolean;
log?: (msg: string) => void;
/** E1b.1 no-clobber guard: invoked right after `relay.getEntry` (the SAME /get
* pushNote already makes — no extra round-trip) and BEFORE any side effect
* (image upload, backup, PUT). Return `{ abort: true, reason }` to skip the PUT
* (fail-safe: a Foundry-side edit since the last baseline must not be
* overwritten). Return `{ abort: false }` (or omit the guard) to proceed. */
prePushGuard?: (liveEntry: JournalEntry) => { abort: boolean; reason?: string };
}
export interface PushOutcome {
@@ -53,6 +59,14 @@ export interface PushOutcome {
imageNote: string;
backupPath?: string;
updatedName?: string;
/** E1b.1: true when `prePushGuard` aborted the push (Foundry-side drift). No
* PUT and no backup were performed. */
aborted?: boolean;
/** E1b.1: the guard's reason when `aborted`. */
abortReason?: string;
/** E1b.1: the live entry fetched by the reused `/get`, exposed so the caller
* can compute/re-baseline `foundry.ccHash` (E1b.2) with no second round-trip. */
liveEntry?: JournalEntry;
}
/** Read the foundry.cc_uuid and the portrait field from a refined note's
@@ -141,6 +155,18 @@ export async function pushNote(deps: PushDeps): Promise<PushOutcome> {
log(`push: fetching live entry ${id} via relay /get…`);
const liveEntry = await deps.relay.getEntry(id);
// E1b.1 no-clobber guard: reuses THIS /get (no extra round-trip). Abort before
// any side effect (image upload, backup, PUT) if Foundry's stored content
// drifted from the note's foundry.ccHash baseline. Fail-safe: a Foundry-side
// edit since the last baseline must not be overwritten.
if (deps.prePushGuard) {
const g = deps.prePushGuard(liveEntry);
if (g.abort) {
log(`push: aborted by prePushGuard — ${g.reason ?? "guard abort"}`);
return { dryRun: deps.dryRun, ccUuid: id, diff: {}, imageNote: "aborted (no image processed)", aborted: true, abortReason: g.reason, liveEntry };
}
}
// Image: upload the portrait into Foundry's assets dir if the note has one.
let imageOverride: string | null | undefined = undefined; // undefined = keep existing
let imageNote = "no portrait field";
@@ -169,7 +195,7 @@ export async function pushNote(deps: PushDeps): Promise<PushOutcome> {
if (deps.dryRun) {
log(`[dry-run] push ${deps.noteName} (${id})`);
return { dryRun: true, ccUuid: id, diff, imageNote };
return { dryRun: true, ccUuid: id, diff, imageNote, liveEntry };
}
// Apply: snapshot the live entry first (reversible), then PUT the diff.
@@ -182,5 +208,5 @@ export async function pushNote(deps: PushDeps): Promise<PushOutcome> {
const updated = await deps.relay.updateEntry(id, diff);
log(`push: updated "${updated.name}" in live Foundry (${id})`);
return { dryRun: false, ccUuid: id, diff, imageNote, backupPath, updatedName: updated.name };
return { dryRun: false, ccUuid: id, diff, imageNote, backupPath, updatedName: updated.name, liveEntry };
}

View File

@@ -21,9 +21,10 @@ import { nameUuidIndexFromEntries, saveNameUuidIndex, loadNameUuidIndex, MapName
import { splitFrontmatter, readFoundryBlock } from "./frontmatter.js";
import { contentHash } from "./normalize.js";
import { SyncLock, relPathLockKey } from "./synclock.js";
import { ccHash } from "./cchash.js";
import { backupStamp } from "./write.js";
import type { RelayConfig, FoundryHostConfig } from "./config.js";
import type { Mode } from "./types.js";
import type { Mode, JournalEntry } from "./types.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const DASHBOARD_PATH = join(__dirname, "dashboard.html");
@@ -33,6 +34,12 @@ const DASHBOARD_PATH = join(__dirname, "dashboard.html");
// When ON, `inflight` is not consulted at all — the two are mutually exclusive.
const SYNC_LOCK_ENABLED = process.env.SYNC_LOCK_ENABLED !== "false";
// E1b.1: Foundry-side no-clobber guard. Default ON; set AUTOSYNC_FOUNDRY_GUARD=false
// to regress to the body-only gate (no ccHash check — documented as unsafe). When
// ON, a push is aborted if Foundry's stored content (ccHash) drifted from the
// note's foundry.ccHash baseline since the last sync.
const AUTOSYNC_FOUNDRY_GUARD = process.env.AUTOSYNC_FOUNDRY_GUARD !== "false";
export interface ServerConfig {
journal: string;
refinedDir: string;
@@ -474,6 +481,9 @@ export class AutoSyncController {
// SYNC_LOCK_ENABLED=true. Public because the F→O poll path (E2) shares this
// one lock instance — it gates uuid+resource, not direction.
readonly lock = new SyncLock();
// E1b.1: Foundry-side no-clobber guard. Read from the module flag so tests can
// override per-controller (the module const itself is fixed at boot).
readonly foundryGuardEnabled = AUTOSYNC_FOUNDRY_GUARD;
private readonly syncLockEnabled = SYNC_LOCK_ENABLED;
// relPath → last-resolved lock key (foundry.cc_uuid or relPath: fallback),
// populated by `process` so the debounce pre-check can fast-skip a redundant
@@ -682,6 +692,34 @@ export class AutoSyncController {
if (bodyHash === fb.contentHash) { this.log(name, "skipped", "unchanged since last push"); return false; }
const relay = relayClient(this.state);
// E1b.1 no-clobber guard: compare the live entry's ccHash (Foundry-side
// content, computed from the SAME /get pushNote already makes — no extra
// round-trip) to the note's foundry.ccHash baseline. If Foundry drifted
// since the last sync, ABORT the PUT (fail-safe — never overwrite a
// Foundry-side edit). A legacy note without foundry.ccHash proceeds
// (one-time migration; E1b.2 writes the post-push ccHash baseline).
// Gated by AUTOSYNC_FOUNDRY_GUARD (default true); off → no guard (body-only).
const guardEnabled = this.foundryGuardEnabled;
const prePushGuard = guardEnabled
? (liveEntry: JournalEntry): { abort: boolean; reason?: string } => {
const baseline = fb?.ccHash;
if (!baseline) return { abort: false }; // legacy note — proceed
let liveHash: string;
try {
liveHash = ccHash(liveEntry);
} catch (e) {
// CcHashError (missing campaign-codex data) — treat as unreadable
// Foundry side: fail-safe, do not push over an entry we can't hash.
return { abort: true, reason: `Foundry side unreadable — skipped (${(e as Error).message})` };
}
if (liveHash !== baseline) {
return { abort: true, reason: "Foundry-side edit detected — skipped (use Sync / Re-pull to reconcile)" };
}
return { abort: false };
}
: undefined;
const outcome = await pushNote({
notePath: abs,
noteName: name,
@@ -691,7 +729,17 @@ export class AutoSyncController {
world: this.state.cfg.foundryCfg?.world ?? "",
dryRun: false, // auto-sync always applies — the whole point is hands-off live push
log: () => {},
prePushGuard,
});
// E1b.1: guard aborted the push (Foundry-side drift or unreadable). No PUT,
// no backup, no baseline. Log as skipped so the DM sees why; baselines are
// left untouched so the next save re-enters the guard cleanly.
if (outcome.aborted) {
this.log(name, "skipped", outcome.abortReason ?? "Foundry-side edit detected — skipped");
return false;
}
// Baseline foundry.contentHash to the new body hash so a re-save with no further
// edit (and the watcher's own baseline write) is a no-op. Idempotency lives here,
// not in pushNote (which always PUTs).

View File

@@ -0,0 +1,192 @@
// E1b.1c — no-clobber regression test. Exercises the REAL pushNote (with the
// prePushGuard) against a MOCKED relay (globalThis.fetch), so we can assert
// whether the PUT (/update) actually fired. The guard compares ccHash(liveEntry
// from /get) to the note's foundry.ccHash baseline and aborts the PUT on drift.
//
// Cases:
// 1. clean (live matches baseline) → guard proceeds → PUT fires.
// 2. drift (Foundry-side edit between baseline and save) → guard aborts → NO PUT.
// 3. legacy note (no foundry.ccHash) → guard proceeds → PUT fires (one-time migration).
// 4. relay /get unreadable (missing campaign-codex data) → fail-safe abort → NO PUT.
//
// Runs with sync-state.json ABSENT (process never touches it) — proving E1b.1
// has no E4 dependency. Live end-to-end (SM-2) stays gated on the operator's
// headless session; this is the offline-testable guard logic.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { AutoSyncController } from "../src/server.js";
import type { State, ServerConfig } from "../src/server.js";
import { ccHash } from "../src/cchash.js";
import type { JournalEntry } from "../src/types.js";
const UUID = "JournalEntry.abc1";
const REL = "Roland.md";
/** Build a live JournalEntry with given description HTML (and empty notes). */
function liveEntry(description: string, name = "Roland"): JournalEntry {
return {
name,
_id: "abc1",
folder: "Folder.test",
flags: { "campaign-codex": { type: "npc", data: { description, notes: "" } } },
};
}
/** A refined note with foundry.cc_uuid + a STALE contentHash baseline (so the
* body-hash gate passes and we reach the ccHash guard) + an optional ccHash. */
function seededNote(ccHashBaseline: string | null): string {
const lines = [
"---",
"type: npc",
"foundry:",
` cc_uuid: ${UUID}`,
" cc_type: npc",
" folder_path: NPCs",
` contentHash: ${"0".repeat(64)}`,
];
if (ccHashBaseline !== null) lines.push(` ccHash: ${ccHashBaseline}`);
lines.push(" syncedAt: 2026-06-22T00:00:00.000Z", "---", "The gunslinger drew his revolver.", "");
return lines.join("\n");
}
let dir: string;
let state: State;
let putCalls: number;
let getCalls: number;
let liveForGet: JournalEntry;
const realFetch = globalThis.fetch;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "e1b1-"));
const refinedDir = join(dir, "refined");
const outDir = join(dir, "out");
await mkdir(refinedDir, { recursive: true });
putCalls = 0;
getCalls = 0;
liveForGet = liveEntry("<p>Original body.</p>"); // default; tests override
const cfg: ServerConfig = {
journal: "",
refinedDir,
ccDir: "",
outDir,
mode: "apply",
port: 0,
host: "",
relayCfg: { url: "http://relay.test", apiKey: "k", clientId: "c" },
};
state = { db: {} as State["db"], cfg, index: null, autosync: null as unknown as State["autosync"] } as unknown as State;
// Mock the relay transport: route by method+path. /get returns liveForGet;
// /update records the PUT; /search returns [] (pushNote builds an empty
// resolver, fine for a body with no [[ ]] links).
globalThis.fetch = vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
const method = init?.method ?? "GET";
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
const resp = (o: unknown, ok = true, status = 200) => ({
ok, status,
text: async () => (typeof o === "string" ? o : JSON.stringify(o)),
});
if (method === "GET" && u.includes("/get?")) { getCalls++; return resp({ data: liveForGet }); }
if (method === "GET" && u.includes("/search")) { return resp({ results: [] }); }
if (method === "PUT" && u.includes("/update?")) {
putCalls++;
// Echo the diff's name into the "updated" entity so pushNote is happy.
const updated = { ...liveForGet, name: (body?.data?.name as string) ?? liveForGet.name };
return resp({ entity: [updated] });
}
return resp({ error: "not found" }, false, 404);
}) as unknown as typeof fetch;
});
afterEach(async () => {
globalThis.fetch = realFetch;
await rm(dir, { recursive: true, force: true });
});
async function writeNote(ccHashBaseline: string | null): Promise<void> {
await writeFile(join(state.cfg.refinedDir, REL), seededNote(ccHashBaseline), "utf8");
}
async function runProcess(): Promise<AutoSyncController> {
const controller = new AutoSyncController(state);
await (controller as any).process(REL);
return controller;
}
describe("E1b.1 no-clobber guard (AutoSyncController + real pushNote + mock relay)", () => {
it("clean: live ccHash matches the foundry.ccHash baseline → guard proceeds → PUT fires", async () => {
const baseline = ccHash(liveEntry("<p>Original body.</p>")); // matches liveForGet
await writeNote(baseline);
const controller = await runProcess();
expect(putCalls).toBe(1); // the push PUT fired
expect(getCalls).toBe(1); // the reused /get
expect(controller.events.some((e) => e.status === "pushed")).toBe(true);
});
it("drift: Foundry-side edit between baseline and save → guard aborts → NO PUT (fail-safe)", async () => {
// Baseline was captured from the original body; Foundry has since been edited.
const baseline = ccHash(liveEntry("<p>Original body.</p>"));
liveForGet = liveEntry("<p>Foundry-edited body.</p>"); // Foundry drifted
await writeNote(baseline);
const controller = await runProcess();
expect(putCalls).toBe(0); // NO PUT — the Foundry-side edit was not overwritten
expect(getCalls).toBe(1); // the /get still happened (reused, no extra round-trip)
const skip = controller.events.find((e) => e.status === "skipped" && e.message.includes("Foundry-side edit detected"));
expect(skip, `expected a "Foundry-side edit detected" skip log; got: ${JSON.stringify(controller.events)}`).toBeTruthy();
});
it("legacy note (no foundry.ccHash) → guard proceeds → PUT fires (one-time migration path)", async () => {
await writeNote(null); // no ccHash baseline
liveForGet = liveEntry("<p>Any body.</p>");
const controller = await runProcess();
expect(putCalls).toBe(1); // legacy notes proceed; E1b.2 will write the post-push ccHash baseline
expect(controller.events.some((e) => e.status === "pushed")).toBe(true);
});
it("relay /get returns an entry missing campaign-codex data → fail-safe abort → NO PUT", async () => {
const baseline = ccHash(liveEntry("<p>Original body.</p>"));
// Malformed live entry: campaign-codex present but data.description missing.
liveForGet = { name: "Roland", _id: "abc1", folder: "Folder.test", flags: { "campaign-codex": { type: "npc" } } };
await writeNote(baseline);
const controller = await runProcess();
expect(putCalls).toBe(0); // can't hash Foundry side → do not push over it
const skip = controller.events.find((e) => e.status === "skipped" && e.message.includes("Foundry side unreadable"));
expect(skip, `expected a "Foundry side unreadable" skip log; got: ${JSON.stringify(controller.events)}`).toBeTruthy();
});
it("guard works with sync-state.json ABSENT (no E4 dependency)", async () => {
// sync-state.json is never created in this test dir; process doesn't touch it.
const baseline = ccHash(liveEntry("<p>Original body.</p>"));
await writeNote(baseline);
const controller = await runProcess();
expect(putCalls).toBe(1);
// No sync-state.json was required for the guard to function.
void controller;
});
});
describe("E1b.1 feature flag AUTOSYNC_FOUNDRY_GUARD (default true)", () => {
it("flag ON (default) → drift detected → NO PUT", async () => {
const baseline = ccHash(liveEntry("<p>Original body.</p>"));
liveForGet = liveEntry("<p>Foundry-edited body.</p>"); // drift
await writeNote(baseline);
const controller = new AutoSyncController(state);
await (controller as any).process(REL);
expect(putCalls).toBe(0); // default ON → guard aborted the drift
});
it("flag OFF → no guard → drift NOT detected → PUT fires (regresses to body-only, UNSAFE — back-compat escape hatch)", async () => {
const baseline = ccHash(liveEntry("<p>Original body.</p>"));
liveForGet = liveEntry("<p>Foundry-edited body.</p>"); // drift
await writeNote(baseline);
const controller = new AutoSyncController(state);
(controller as any).foundryGuardEnabled = false; // flag off
await (controller as any).process(REL);
expect(putCalls).toBe(1); // unsafe: the Foundry-side edit was overwritten
});
});