@git-stunts/git-warp 10.4.2 → 10.7.0

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/SECURITY.md CHANGED
@@ -25,6 +25,94 @@ The `GitGraphAdapter` validates all ref arguments to prevent injection attacks:
25
25
  - **Bitmap Indexing**: Sharded Roaring Bitmap indexes enable O(1) lookups without loading entire graphs
26
26
  - **Delimiter Safety**: Uses ASCII Record Separator (`\x1E`) to prevent message collision
27
27
 
28
- ## 🐞 Reporting a Vulnerability
28
+ ## Sync Authentication (SHIELD)
29
+
30
+ ### Overview
31
+
32
+ The HTTP sync protocol supports optional HMAC-SHA256 request signing with replay protection. When enabled, every sync request must carry a valid signature computed over a canonical payload that includes the request body, timestamp, and a unique nonce.
33
+
34
+ ### Threat Model
35
+
36
+ **Protected against:**
37
+ - Unauthorized sync requests from unknown peers
38
+ - Replay attacks (nonce-based, with 5-minute TTL window)
39
+ - Request body tampering (HMAC covers body SHA-256)
40
+ - Timing attacks on signature comparison (`timingSafeEqual`)
41
+
42
+ **Not protected against:**
43
+ - Compromised shared secrets (rotate keys immediately if leaked)
44
+ - Denial-of-service (body size limits provide basic protection, but no rate limiting)
45
+ - Man-in-the-middle without TLS (use HTTPS in production)
46
+
47
+ ### Authentication Flow
48
+
49
+ 1. Client computes SHA-256 of request body
50
+ 2. Client builds canonical payload: `warp-v1|KEY_ID|METHOD|PATH|TIMESTAMP|NONCE|CONTENT_TYPE|BODY_SHA256`
51
+ 3. Client computes HMAC-SHA256 of canonical payload using shared secret
52
+ 4. Client sends 5 auth headers: `x-warp-sig-version`, `x-warp-key-id`, `x-warp-timestamp`, `x-warp-nonce`, `x-warp-signature`
53
+ 5. Server validates header formats (cheap checks first)
54
+ 6. Server checks clock skew (default: 5 minutes)
55
+ 7. Server reserves nonce atomically (prevents replay)
56
+ 8. Server resolves key by key-id
57
+ 9. Server recomputes HMAC and compares with constant-time equality
58
+
59
+ ### Enforcement Modes
60
+
61
+ - **`enforce`** (default): Rejects requests that fail authentication with appropriate HTTP status codes (400/401/403). No request details leak in error responses.
62
+ - **`log-only`**: Logs authentication failures but allows requests through. Use during rollout to identify issues before enforcing.
63
+
64
+ ### Error Response Hygiene
65
+
66
+ External error responses use coarse status codes and generic reason strings:
67
+ - `400` — Malformed headers (version, timestamp, nonce, signature format)
68
+ - `401` — Missing auth headers, unknown key-id, invalid signature
69
+ - `403` — Expired timestamp, replayed nonce
70
+
71
+ Detailed diagnostics (exact failure reason, key-id, peer info) are sent to the structured logger only.
72
+
73
+ ### Nonce Cache and Restart Semantics
74
+
75
+ The nonce cache is an in-memory LRU (default capacity: 100,000 entries). On server restart, the cache is empty. This means:
76
+ - Nonces from before the restart can be replayed within the 5-minute clock skew window
77
+ - This is an accepted trade-off for simplicity; persistent nonce storage is not implemented in v1
78
+ - For higher security, keep the clock skew window small and use TLS
79
+
80
+ ### Key Rotation
81
+
82
+ Key management uses a key-id system for zero-downtime rotation:
83
+
84
+ 1. Add the new key-id and secret to the server's `keys` map
85
+ 2. Deploy the server
86
+ 3. Update clients to use the new key-id
87
+ 4. Remove the old key-id from the server's `keys` map
88
+ 5. Deploy again
89
+
90
+ Multiple key-ids can coexist indefinitely.
91
+
92
+ ### Configuration
93
+
94
+ **Server (`serve()`):**
95
+ ```js
96
+ await graph.serve({
97
+ port: 3000,
98
+ httpPort: new NodeHttpAdapter(),
99
+ auth: {
100
+ keys: { default: 'your-shared-secret' },
101
+ mode: 'enforce', // or 'log-only'
102
+ },
103
+ });
104
+ ```
105
+
106
+ **Client (`syncWith()`):**
107
+ ```js
108
+ await graph.syncWith('http://peer:3000', {
109
+ auth: {
110
+ secret: 'your-shared-secret',
111
+ keyId: 'default',
112
+ },
113
+ });
114
+ ```
115
+
116
+ ## Reporting a Vulnerability
29
117
 
30
118
  If you discover a security vulnerability, please send an e-mail to [james@flyingrobots.dev](mailto:james@flyingrobots.dev).
package/bin/warp-graph.js CHANGED
@@ -31,7 +31,8 @@ import { renderHistoryView, summarizeOps } from '../src/visualization/renderers/
31
31
  import { renderPathView } from '../src/visualization/renderers/ascii/path.js';
32
32
  import { renderMaterializeView } from '../src/visualization/renderers/ascii/materialize.js';
33
33
  import { parseCursorBlob } from '../src/domain/utils/parseCursorBlob.js';
34
- import { renderSeekView } from '../src/visualization/renderers/ascii/seek.js';
34
+ import { diffStates } from '../src/domain/services/StateDiff.js';
35
+ import { renderSeekView, formatStructuralDiff } from '../src/visualization/renderers/ascii/seek.js';
35
36
  import { renderGraphView } from '../src/visualization/renderers/ascii/graph.js';
36
37
  import { renderSvg } from '../src/visualization/renderers/svg/index.js';
37
38
  import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../src/visualization/layouts/index.js';
@@ -63,6 +64,7 @@ import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../s
63
64
  * @property {() => Promise<Map<string, any>>} getFrontier
64
65
  * @property {() => {totalTombstones: number, tombstoneRatio: number}} getGCMetrics
65
66
  * @property {() => Promise<number>} getPropertyCount
67
+ * @property {() => Promise<import('../src/domain/services/JoinReducer.js').WarpStateV5 | null>} getStateSnapshot
66
68
  * @property {() => Promise<{ticks: number[], maxTick: number, perWriter: Map<string, WriterTickInfo>}>} discoverTicks
67
69
  * @property {(sha: string) => Promise<{ops?: any[]}>} loadPatchBySha
68
70
  * @property {(cache: any) => void} setSeekCache
@@ -113,6 +115,8 @@ import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../s
113
115
  * @property {string|null} tickValue
114
116
  * @property {string|null} name
115
117
  * @property {boolean} noPersistentCache
118
+ * @property {boolean} diff
119
+ * @property {number} diffLimit
116
120
  */
117
121
 
118
122
  const EXIT_CODES = {
@@ -171,6 +175,8 @@ Seek options:
171
175
  --load <name> Restore a saved cursor
172
176
  --list List all saved cursors
173
177
  --drop <name> Delete a saved cursor
178
+ --diff Show structural diff (added/removed nodes, edges, props)
179
+ --diff-limit <N> Max diff entries (default 2000)
174
180
  `;
175
181
 
176
182
  /**
@@ -1953,9 +1959,64 @@ function handleSeekBooleanFlag(arg, spec) {
1953
1959
  spec.action = 'clear-cache';
1954
1960
  } else if (arg === '--no-persistent-cache') {
1955
1961
  spec.noPersistentCache = true;
1962
+ } else if (arg === '--diff') {
1963
+ spec.diff = true;
1956
1964
  }
1957
1965
  }
1958
1966
 
1967
+ /**
1968
+ * Parses --diff-limit / --diff-limit=N into the seek spec.
1969
+ * @param {string} arg
1970
+ * @param {string[]} args
1971
+ * @param {number} i
1972
+ * @param {SeekSpec} spec
1973
+ */
1974
+ function handleDiffLimitFlag(arg, args, i, spec) {
1975
+ let raw;
1976
+ if (arg.startsWith('--diff-limit=')) {
1977
+ raw = arg.slice('--diff-limit='.length);
1978
+ } else {
1979
+ raw = args[i + 1];
1980
+ if (raw === undefined) {
1981
+ throw usageError('Missing value for --diff-limit');
1982
+ }
1983
+ }
1984
+ const n = Number(raw);
1985
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
1986
+ throw usageError(`Invalid --diff-limit value: ${raw}. Must be a positive integer.`);
1987
+ }
1988
+ spec.diffLimit = n;
1989
+ }
1990
+
1991
+ /**
1992
+ * Parses a named action flag (--save, --load, --drop) with its value.
1993
+ * @param {string} flagName - e.g. 'save'
1994
+ * @param {string} arg - Current arg token
1995
+ * @param {string[]} args - All args
1996
+ * @param {number} i - Current index
1997
+ * @param {SeekSpec} spec
1998
+ * @returns {number} Number of extra args consumed (0 or 1)
1999
+ */
2000
+ function parseSeekNamedAction(flagName, arg, args, i, spec) {
2001
+ if (spec.action !== 'status') {
2002
+ throw usageError(`--${flagName} cannot be combined with other seek flags`);
2003
+ }
2004
+ spec.action = flagName;
2005
+ if (arg === `--${flagName}`) {
2006
+ const val = args[i + 1];
2007
+ if (val === undefined || val.startsWith('-')) {
2008
+ throw usageError(`Missing name for --${flagName}`);
2009
+ }
2010
+ spec.name = val;
2011
+ return 1;
2012
+ }
2013
+ spec.name = arg.slice(`--${flagName}=`.length);
2014
+ if (!spec.name) {
2015
+ throw usageError(`Missing name for --${flagName}`);
2016
+ }
2017
+ return 0;
2018
+ }
2019
+
1959
2020
  /**
1960
2021
  * Parses CLI arguments for the `seek` command into a structured spec.
1961
2022
  * @param {string[]} args - Raw CLI arguments following the `seek` subcommand
@@ -1968,7 +2029,10 @@ function parseSeekArgs(args) {
1968
2029
  tickValue: null,
1969
2030
  name: null,
1970
2031
  noPersistentCache: false,
2032
+ diff: false,
2033
+ diffLimit: 2000,
1971
2034
  };
2035
+ let diffLimitProvided = false;
1972
2036
 
1973
2037
  for (let i = 0; i < args.length; i++) {
1974
2038
  const arg = args[i];
@@ -1995,78 +2059,39 @@ function parseSeekArgs(args) {
1995
2059
  throw usageError('--latest cannot be combined with other seek flags');
1996
2060
  }
1997
2061
  spec.action = 'latest';
1998
- } else if (arg === '--save') {
1999
- if (spec.action !== 'status') {
2000
- throw usageError('--save cannot be combined with other seek flags');
2001
- }
2002
- spec.action = 'save';
2003
- const val = args[i + 1];
2004
- if (val === undefined || val.startsWith('-')) {
2005
- throw usageError('Missing name for --save');
2006
- }
2007
- spec.name = val;
2008
- i += 1;
2009
- } else if (arg.startsWith('--save=')) {
2010
- if (spec.action !== 'status') {
2011
- throw usageError('--save cannot be combined with other seek flags');
2012
- }
2013
- spec.action = 'save';
2014
- spec.name = arg.slice('--save='.length);
2015
- if (!spec.name) {
2016
- throw usageError('Missing name for --save');
2017
- }
2018
- } else if (arg === '--load') {
2019
- if (spec.action !== 'status') {
2020
- throw usageError('--load cannot be combined with other seek flags');
2021
- }
2022
- spec.action = 'load';
2023
- const val = args[i + 1];
2024
- if (val === undefined || val.startsWith('-')) {
2025
- throw usageError('Missing name for --load');
2026
- }
2027
- spec.name = val;
2028
- i += 1;
2029
- } else if (arg.startsWith('--load=')) {
2030
- if (spec.action !== 'status') {
2031
- throw usageError('--load cannot be combined with other seek flags');
2032
- }
2033
- spec.action = 'load';
2034
- spec.name = arg.slice('--load='.length);
2035
- if (!spec.name) {
2036
- throw usageError('Missing name for --load');
2037
- }
2062
+ } else if (arg === '--save' || arg.startsWith('--save=')) {
2063
+ i += parseSeekNamedAction('save', arg, args, i, spec);
2064
+ } else if (arg === '--load' || arg.startsWith('--load=')) {
2065
+ i += parseSeekNamedAction('load', arg, args, i, spec);
2038
2066
  } else if (arg === '--list') {
2039
2067
  if (spec.action !== 'status') {
2040
2068
  throw usageError('--list cannot be combined with other seek flags');
2041
2069
  }
2042
2070
  spec.action = 'list';
2043
- } else if (arg === '--drop') {
2044
- if (spec.action !== 'status') {
2045
- throw usageError('--drop cannot be combined with other seek flags');
2046
- }
2047
- spec.action = 'drop';
2048
- const val = args[i + 1];
2049
- if (val === undefined || val.startsWith('-')) {
2050
- throw usageError('Missing name for --drop');
2051
- }
2052
- spec.name = val;
2053
- i += 1;
2054
- } else if (arg.startsWith('--drop=')) {
2055
- if (spec.action !== 'status') {
2056
- throw usageError('--drop cannot be combined with other seek flags');
2057
- }
2058
- spec.action = 'drop';
2059
- spec.name = arg.slice('--drop='.length);
2060
- if (!spec.name) {
2061
- throw usageError('Missing name for --drop');
2062
- }
2063
- } else if (arg === '--clear-cache' || arg === '--no-persistent-cache') {
2071
+ } else if (arg === '--drop' || arg.startsWith('--drop=')) {
2072
+ i += parseSeekNamedAction('drop', arg, args, i, spec);
2073
+ } else if (arg === '--clear-cache' || arg === '--no-persistent-cache' || arg === '--diff') {
2064
2074
  handleSeekBooleanFlag(arg, spec);
2075
+ } else if (arg === '--diff-limit' || arg.startsWith('--diff-limit=')) {
2076
+ handleDiffLimitFlag(arg, args, i, spec);
2077
+ diffLimitProvided = true;
2078
+ if (arg === '--diff-limit') {
2079
+ i += 1;
2080
+ }
2065
2081
  } else if (arg.startsWith('-')) {
2066
2082
  throw usageError(`Unknown seek option: ${arg}`);
2067
2083
  }
2068
2084
  }
2069
2085
 
2086
+ // --diff is only meaningful for actions that navigate to a tick
2087
+ const DIFF_ACTIONS = new Set(['tick', 'latest', 'load']);
2088
+ if (spec.diff && !DIFF_ACTIONS.has(spec.action)) {
2089
+ throw usageError(`--diff cannot be used with --${spec.action}`);
2090
+ }
2091
+ if (diffLimitProvided && !spec.diff) {
2092
+ throw usageError('--diff-limit requires --diff');
2093
+ }
2094
+
2070
2095
  return spec;
2071
2096
  }
2072
2097
 
@@ -2189,8 +2214,16 @@ async function handleSeek({ options, args }) {
2189
2214
  };
2190
2215
  }
2191
2216
  if (seekSpec.action === 'latest') {
2217
+ const prevTick = activeCursor ? activeCursor.tick : null;
2218
+ let sdResult = null;
2219
+ if (seekSpec.diff) {
2220
+ sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: maxTick, diffLimit: seekSpec.diffLimit });
2221
+ }
2192
2222
  await clearActiveCursor(persistence, graphName);
2193
- await graph.materialize();
2223
+ // When --diff already materialized at maxTick, skip redundant re-materialize
2224
+ if (!sdResult) {
2225
+ await graph.materialize({ ceiling: maxTick });
2226
+ }
2194
2227
  const nodes = await graph.getNodes();
2195
2228
  const edges = await graph.getEdges();
2196
2229
  const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
@@ -2209,6 +2242,7 @@ async function handleSeek({ options, args }) {
2209
2242
  diff,
2210
2243
  tickReceipt,
2211
2244
  cursor: { active: false },
2245
+ ...sdResult,
2212
2246
  },
2213
2247
  exitCode: EXIT_CODES.OK,
2214
2248
  };
@@ -2234,7 +2268,15 @@ async function handleSeek({ options, args }) {
2234
2268
  if (!saved) {
2235
2269
  throw notFoundError(`Saved cursor not found: ${loadName}`);
2236
2270
  }
2237
- await graph.materialize({ ceiling: saved.tick });
2271
+ const prevTick = activeCursor ? activeCursor.tick : null;
2272
+ let sdResult = null;
2273
+ if (seekSpec.diff) {
2274
+ sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: saved.tick, diffLimit: seekSpec.diffLimit });
2275
+ }
2276
+ // When --diff already materialized at saved.tick, skip redundant call
2277
+ if (!sdResult) {
2278
+ await graph.materialize({ ceiling: saved.tick });
2279
+ }
2238
2280
  const nodes = await graph.getNodes();
2239
2281
  const edges = await graph.getEdges();
2240
2282
  await writeActiveCursor(persistence, graphName, { tick: saved.tick, mode: saved.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
@@ -2255,6 +2297,7 @@ async function handleSeek({ options, args }) {
2255
2297
  diff,
2256
2298
  tickReceipt,
2257
2299
  cursor: { active: true, mode: saved.mode, tick: saved.tick, maxTick, name: seekSpec.name },
2300
+ ...sdResult,
2258
2301
  },
2259
2302
  exitCode: EXIT_CODES.OK,
2260
2303
  };
@@ -2262,7 +2305,14 @@ async function handleSeek({ options, args }) {
2262
2305
  if (seekSpec.action === 'tick') {
2263
2306
  const currentTick = activeCursor ? activeCursor.tick : null;
2264
2307
  const resolvedTick = resolveTickValue(/** @type {string} */ (seekSpec.tickValue), currentTick, ticks, maxTick);
2265
- await graph.materialize({ ceiling: resolvedTick });
2308
+ let sdResult = null;
2309
+ if (seekSpec.diff) {
2310
+ sdResult = await computeStructuralDiff({ graph, prevTick: currentTick, currentTick: resolvedTick, diffLimit: seekSpec.diffLimit });
2311
+ }
2312
+ // When --diff already materialized at resolvedTick, skip redundant call
2313
+ if (!sdResult) {
2314
+ await graph.materialize({ ceiling: resolvedTick });
2315
+ }
2266
2316
  const nodes = await graph.getNodes();
2267
2317
  const edges = await graph.getEdges();
2268
2318
  await writeActiveCursor(persistence, graphName, { tick: resolvedTick, mode: 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
@@ -2282,12 +2332,22 @@ async function handleSeek({ options, args }) {
2282
2332
  diff,
2283
2333
  tickReceipt,
2284
2334
  cursor: { active: true, mode: 'lamport', tick: resolvedTick, maxTick, name: 'active' },
2335
+ ...sdResult,
2285
2336
  },
2286
2337
  exitCode: EXIT_CODES.OK,
2287
2338
  };
2288
2339
  }
2289
2340
 
2290
2341
  // status (bare seek)
2342
+ return await handleSeekStatus({ graph, graphName, persistence, activeCursor, ticks, maxTick, perWriter, frontierHash });
2343
+ }
2344
+
2345
+ /**
2346
+ * Handles the `status` sub-action of `seek` (bare seek with no action flag).
2347
+ * @param {{graph: WarpGraphInstance, graphName: string, persistence: Persistence, activeCursor: CursorBlob|null, ticks: number[], maxTick: number, perWriter: Map<string, WriterTickInfo>, frontierHash: string}} params
2348
+ * @returns {Promise<{payload: *, exitCode: number}>}
2349
+ */
2350
+ async function handleSeekStatus({ graph, graphName, persistence, activeCursor, ticks, maxTick, perWriter, frontierHash }) {
2291
2351
  if (activeCursor) {
2292
2352
  await graph.materialize({ ceiling: activeCursor.tick });
2293
2353
  const nodes = await graph.getNodes();
@@ -2468,6 +2528,79 @@ async function buildTickReceipt({ tick, perWriter, graph }) {
2468
2528
  return Object.keys(receipt).length > 0 ? receipt : null;
2469
2529
  }
2470
2530
 
2531
+ /**
2532
+ * Computes a structural diff between the state at a previous tick and
2533
+ * the state at the current tick.
2534
+ *
2535
+ * Materializes the baseline tick first, snapshots the state, then
2536
+ * materializes the target tick and calls diffStates() between the two.
2537
+ * Applies diffLimit truncation when the total change count exceeds the cap.
2538
+ *
2539
+ * @param {{graph: WarpGraphInstance, prevTick: number|null, currentTick: number, diffLimit: number}} params
2540
+ * @returns {Promise<{structuralDiff: *, diffBaseline: string, baselineTick: number|null, truncated: boolean, totalChanges: number, shownChanges: number}>}
2541
+ */
2542
+ async function computeStructuralDiff({ graph, prevTick, currentTick, diffLimit }) {
2543
+ let beforeState = null;
2544
+ let diffBaseline = 'empty';
2545
+ let baselineTick = null;
2546
+
2547
+ // Short-circuit: same tick produces an empty diff
2548
+ if (prevTick !== null && prevTick === currentTick) {
2549
+ const empty = { nodes: { added: [], removed: [] }, edges: { added: [], removed: [] }, props: { set: [], removed: [] } };
2550
+ return { structuralDiff: empty, diffBaseline: 'tick', baselineTick: prevTick, truncated: false, totalChanges: 0, shownChanges: 0 };
2551
+ }
2552
+
2553
+ if (prevTick !== null && prevTick > 0) {
2554
+ await graph.materialize({ ceiling: prevTick });
2555
+ beforeState = await graph.getStateSnapshot();
2556
+ diffBaseline = 'tick';
2557
+ baselineTick = prevTick;
2558
+ }
2559
+
2560
+ await graph.materialize({ ceiling: currentTick });
2561
+ const afterState = /** @type {*} */ (await graph.getStateSnapshot()); // TODO(ts-cleanup): narrow WarpStateV5
2562
+ const diff = diffStates(beforeState, afterState);
2563
+
2564
+ return applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit);
2565
+ }
2566
+
2567
+ /**
2568
+ * Applies truncation limits to a structural diff result.
2569
+ *
2570
+ * @param {*} diff
2571
+ * @param {string} diffBaseline
2572
+ * @param {number|null} baselineTick
2573
+ * @param {number} diffLimit
2574
+ * @returns {{structuralDiff: *, diffBaseline: string, baselineTick: number|null, truncated: boolean, totalChanges: number, shownChanges: number}}
2575
+ */
2576
+ function applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit) {
2577
+ const totalChanges =
2578
+ diff.nodes.added.length + diff.nodes.removed.length +
2579
+ diff.edges.added.length + diff.edges.removed.length +
2580
+ diff.props.set.length + diff.props.removed.length;
2581
+
2582
+ if (totalChanges <= diffLimit) {
2583
+ return { structuralDiff: diff, diffBaseline, baselineTick, truncated: false, totalChanges, shownChanges: totalChanges };
2584
+ }
2585
+
2586
+ // Truncate sequentially (nodes → edges → props), keeping sort order within each category
2587
+ let remaining = diffLimit;
2588
+ const cap = (/** @type {any[]} */ arr) => {
2589
+ const take = Math.min(arr.length, remaining);
2590
+ remaining -= take;
2591
+ return arr.slice(0, take);
2592
+ };
2593
+
2594
+ const capped = {
2595
+ nodes: { added: cap(diff.nodes.added), removed: cap(diff.nodes.removed) },
2596
+ edges: { added: cap(diff.edges.added), removed: cap(diff.edges.removed) },
2597
+ props: { set: cap(diff.props.set), removed: cap(diff.props.removed) },
2598
+ };
2599
+
2600
+ const shownChanges = diffLimit - remaining;
2601
+ return { structuralDiff: capped, diffBaseline, baselineTick, truncated: true, totalChanges, shownChanges };
2602
+ }
2603
+
2471
2604
  /**
2472
2605
  * Renders a seek command payload as a human-readable string for terminal output.
2473
2606
  *
@@ -2567,26 +2700,29 @@ function renderSeek(payload) {
2567
2700
 
2568
2701
  if (payload.action === 'latest') {
2569
2702
  const { nodesStr, edgesStr } = buildStateStrings();
2570
- return appendReceiptSummary(
2703
+ const base = appendReceiptSummary(
2571
2704
  `${payload.graph}: returned to present (tick ${payload.maxTick}, ${nodesStr}, ${edgesStr})`,
2572
2705
  );
2706
+ return base + formatStructuralDiff(payload);
2573
2707
  }
2574
2708
 
2575
2709
  if (payload.action === 'load') {
2576
2710
  const { nodesStr, edgesStr } = buildStateStrings();
2577
- return appendReceiptSummary(
2711
+ const base = appendReceiptSummary(
2578
2712
  `${payload.graph}: loaded cursor "${payload.name}" at tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr})`,
2579
2713
  );
2714
+ return base + formatStructuralDiff(payload);
2580
2715
  }
2581
2716
 
2582
2717
  if (payload.action === 'tick') {
2583
2718
  const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
2584
- return appendReceiptSummary(
2719
+ const base = appendReceiptSummary(
2585
2720
  `${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
2586
2721
  );
2722
+ return base + formatStructuralDiff(payload);
2587
2723
  }
2588
2724
 
2589
- // status
2725
+ // status (structuralDiff is never populated here; no formatStructuralDiff call)
2590
2726
  if (payload.cursor && payload.cursor.active) {
2591
2727
  const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
2592
2728
  return appendReceiptSummary(
package/index.d.ts CHANGED
@@ -1413,6 +1413,22 @@ export interface ApplySyncResult {
1413
1413
  applied: number;
1414
1414
  }
1415
1415
 
1416
+ /**
1417
+ * Server-side auth configuration for serve().
1418
+ */
1419
+ export interface SyncAuthServerOptions {
1420
+ keys: Record<string, string>;
1421
+ mode?: 'enforce' | 'log-only';
1422
+ }
1423
+
1424
+ /**
1425
+ * Client-side auth credentials for syncWith().
1426
+ */
1427
+ export interface SyncAuthClientOptions {
1428
+ secret: string;
1429
+ keyId?: string;
1430
+ }
1431
+
1416
1432
  // ============================================================================
1417
1433
  // Status snapshot
1418
1434
  // ============================================================================
@@ -1526,6 +1542,12 @@ export default class WarpGraph {
1526
1542
  */
1527
1543
  getPropertyCount(): Promise<number>;
1528
1544
 
1545
+ /**
1546
+ * Returns a defensive copy of the current materialized state,
1547
+ * or null if no state has been materialized yet.
1548
+ */
1549
+ getStateSnapshot(): Promise<WarpStateV5 | null>;
1550
+
1529
1551
  /**
1530
1552
  * Gets all properties for an edge from the materialized state.
1531
1553
  * Returns null if the edge does not exist or is tombstoned.
@@ -1615,6 +1637,7 @@ export default class WarpGraph {
1615
1637
  path?: string;
1616
1638
  maxRequestBytes?: number;
1617
1639
  httpPort: HttpServerPort;
1640
+ auth?: SyncAuthServerOptions;
1618
1641
  }): Promise<{ close(): Promise<void>; url: string }>;
1619
1642
 
1620
1643
  /**
@@ -1634,6 +1657,7 @@ export default class WarpGraph {
1634
1657
  status?: number;
1635
1658
  error?: Error;
1636
1659
  }) => void;
1660
+ auth?: SyncAuthClientOptions;
1637
1661
  }): Promise<{ applied: number; attempts: number }>;
1638
1662
 
1639
1663
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@git-stunts/git-warp",
3
- "version": "10.4.2",
3
+ "version": "10.7.0",
4
4
  "description": "Deterministic WARP graph over Git: graph-native storage, traversal, and tooling.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -74,7 +74,6 @@
74
74
  "demo:bench-streaming": "cd examples && docker compose up -d && docker compose exec demo node /app/examples/streaming-benchmark.js",
75
75
  "demo:bench-traversal": "cd examples && docker compose up -d && docker compose exec demo node /app/examples/traversal-benchmark.js",
76
76
  "demo:down": "cd examples && docker compose down -v",
77
- "roadmap": "node scripts/roadmap.js",
78
77
  "setup:hooks": "node scripts/setup-hooks.js",
79
78
  "prepare": "patch-package && node scripts/setup-hooks.js",
80
79
  "prepack": "npm run lint && npm run test:local",