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:
30
src/push.ts
30
src/push.ts
@@ -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 };
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
192
tests/e1b1-noclobber.test.ts
Normal file
192
tests/e1b1-noclobber.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user