@git-stunts/git-warp 12.1.0 → 12.2.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/README.md +5 -3
- package/bin/warp-graph.js +6 -2
- package/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/domain/WarpGraph.js +3 -0
- package/src/domain/crdt/ORSet.js +33 -4
- package/src/domain/errors/SyncError.js +1 -0
- package/src/domain/errors/TrustError.js +2 -0
- package/src/domain/services/CheckpointService.js +2 -7
- package/src/domain/services/Frontier.js +18 -0
- package/src/domain/services/GraphTraversal.js +8 -49
- package/src/domain/services/HttpSyncServer.js +18 -29
- package/src/domain/services/JoinReducer.js +23 -0
- package/src/domain/services/PatchBuilderV2.js +29 -3
- package/src/domain/services/QueryBuilder.js +63 -30
- package/src/domain/services/SyncController.js +74 -11
- package/src/domain/services/SyncPayloadSchema.js +236 -0
- package/src/domain/services/SyncProtocol.js +27 -8
- package/src/domain/services/SyncTrustGate.js +146 -0
- package/src/domain/services/TranslationCost.js +2 -2
- package/src/domain/trust/TrustRecordService.js +119 -6
- package/src/domain/warp/Writer.js +7 -5
- package/src/domain/warp/checkpoint.methods.js +66 -9
- package/src/domain/warp/materialize.methods.js +3 -0
- package/src/domain/warp/materializeAdvanced.methods.js +2 -0
- package/src/domain/warp/patch.methods.js +8 -0
- package/src/domain/warp/query.methods.js +4 -4
- package/src/domain/warp/subscribe.methods.js +11 -19
- package/src/infrastructure/adapters/GitGraphAdapter.js +2 -2
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import SyncError from '../errors/SyncError.js';
|
|
12
12
|
import OperationAbortedError from '../errors/OperationAbortedError.js';
|
|
13
13
|
import { QueryError, E_NO_STATE_MSG } from '../warp/_internal.js';
|
|
14
|
+
import { validateSyncResponse } from './SyncPayloadSchema.js';
|
|
14
15
|
import {
|
|
15
16
|
createSyncRequest as createSyncRequestImpl,
|
|
16
17
|
processSyncRequest as processSyncRequestImpl,
|
|
@@ -25,6 +26,7 @@ import { collectGCMetrics } from './GCMetrics.js';
|
|
|
25
26
|
import HttpSyncServer from './HttpSyncServer.js';
|
|
26
27
|
import { signSyncRequest, canonicalizePath } from './SyncAuthService.js';
|
|
27
28
|
import { isError } from '../types/WarpErrors.js';
|
|
29
|
+
import SyncTrustGate from './SyncTrustGate.js';
|
|
28
30
|
|
|
29
31
|
/** @typedef {import('../types/WarpPersistence.js').CorePersistence} CorePersistence */
|
|
30
32
|
|
|
@@ -128,10 +130,14 @@ async function buildSyncAuthHeaders({ auth, bodyStr, targetUrl, crypto }) {
|
|
|
128
130
|
export default class SyncController {
|
|
129
131
|
/**
|
|
130
132
|
* @param {SyncHost} host - The WarpGraph instance (or any object satisfying SyncHost)
|
|
133
|
+
* @param {Object} [options]
|
|
134
|
+
* @param {SyncTrustGate} [options.trustGate] - Trust gate for evaluating patch authors
|
|
131
135
|
*/
|
|
132
|
-
constructor(host) {
|
|
136
|
+
constructor(host, options = {}) {
|
|
133
137
|
/** @type {SyncHost} */
|
|
134
138
|
this._host = host;
|
|
139
|
+
/** @type {SyncTrustGate|null} */
|
|
140
|
+
this._trustGate = options.trustGate || null;
|
|
135
141
|
}
|
|
136
142
|
|
|
137
143
|
/**
|
|
@@ -268,7 +274,7 @@ export default class SyncController {
|
|
|
268
274
|
localFrontier,
|
|
269
275
|
persistence,
|
|
270
276
|
this._host._graphName,
|
|
271
|
-
{ codec: this._host._codec }
|
|
277
|
+
{ codec: this._host._codec, logger: this._host._logger || undefined }
|
|
272
278
|
);
|
|
273
279
|
}
|
|
274
280
|
|
|
@@ -282,13 +288,48 @@ export default class SyncController {
|
|
|
282
288
|
* @returns {{state: import('./JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number}} Result with updated state and frontier
|
|
283
289
|
* @throws {import('../errors/QueryError.js').default} If no cached state exists (code: `E_NO_STATE`)
|
|
284
290
|
*/
|
|
285
|
-
|
|
291
|
+
/**
|
|
292
|
+
* Applies a sync response to the local graph state.
|
|
293
|
+
* Updates the cached state with received patches.
|
|
294
|
+
*
|
|
295
|
+
* When a trust gate is configured, evaluates patch authors (writersApplied)
|
|
296
|
+
* against trust policy. In enforce mode, untrusted writers cause rejection
|
|
297
|
+
* before any state mutation.
|
|
298
|
+
*
|
|
299
|
+
* **Requires a cached state.**
|
|
300
|
+
*
|
|
301
|
+
* @param {import('./SyncProtocol.js').SyncResponse} response - The sync response
|
|
302
|
+
* @returns {Promise<{state: import('./JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number, trustVerdict?: string, writersApplied?: string[]}>} Result with updated state and frontier
|
|
303
|
+
* @throws {import('../errors/QueryError.js').default} If no cached state exists (code: `E_NO_STATE`)
|
|
304
|
+
* @throws {SyncError} If trust gate rejects untrusted writers (code: `E_SYNC_UNTRUSTED_WRITER`)
|
|
305
|
+
*/
|
|
306
|
+
async applySyncResponse(response) {
|
|
286
307
|
if (!this._host._cachedState) {
|
|
287
308
|
throw new QueryError(E_NO_STATE_MSG, {
|
|
288
309
|
code: 'E_NO_STATE',
|
|
289
310
|
});
|
|
290
311
|
}
|
|
291
312
|
|
|
313
|
+
// Extract actual patch authors for trust evaluation (B1)
|
|
314
|
+
const writersApplied = SyncTrustGate.extractWritersFromPatches(response.patches || []);
|
|
315
|
+
|
|
316
|
+
// Evaluate trust BEFORE applying any patches
|
|
317
|
+
if (this._trustGate && writersApplied.length > 0) {
|
|
318
|
+
const verdict = await this._trustGate.evaluate(writersApplied, {
|
|
319
|
+
graphName: this._host._graphName,
|
|
320
|
+
});
|
|
321
|
+
if (!verdict.allowed) {
|
|
322
|
+
throw new SyncError('Sync rejected: untrusted writer(s)', {
|
|
323
|
+
code: 'E_SYNC_UNTRUSTED_WRITER',
|
|
324
|
+
context: {
|
|
325
|
+
writersApplied,
|
|
326
|
+
untrustedWriters: verdict.untrustedWriters,
|
|
327
|
+
verdict: verdict.verdict,
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
292
333
|
const currentFrontier = this._host._lastFrontier || createFrontier();
|
|
293
334
|
const result = /** @type {{state: import('./JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number}} */ (applySyncResponseImpl(response, this._host._cachedState, currentFrontier));
|
|
294
335
|
|
|
@@ -301,10 +342,32 @@ export default class SyncController {
|
|
|
301
342
|
// Track patches for GC
|
|
302
343
|
this._host._patchesSinceGC += result.applied;
|
|
303
344
|
|
|
345
|
+
// Invalidate derived caches (C1) — sync changes underlying state
|
|
346
|
+
this._invalidateDerivedCaches();
|
|
347
|
+
|
|
304
348
|
// State is now in sync with the frontier -- clear dirty flag
|
|
305
349
|
this._host._stateDirty = false;
|
|
306
350
|
|
|
307
|
-
return result;
|
|
351
|
+
return { ...result, writersApplied };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Invalidates all derived caches on the host graph.
|
|
356
|
+
*
|
|
357
|
+
* Called after sync apply or join to ensure stale index/provider/view
|
|
358
|
+
* data is not returned to callers. The next query or traversal will
|
|
359
|
+
* trigger a rebuild.
|
|
360
|
+
*
|
|
361
|
+
* @private
|
|
362
|
+
*/
|
|
363
|
+
_invalidateDerivedCaches() {
|
|
364
|
+
const h = /** @type {import('../WarpGraph.js').default} */ (this._host);
|
|
365
|
+
h._materializedGraph = null;
|
|
366
|
+
h._logicalIndex = null;
|
|
367
|
+
h._propertyReader = null;
|
|
368
|
+
h._cachedViewHash = null;
|
|
369
|
+
h._cachedIndexTree = null;
|
|
370
|
+
h._stateDirty = true;
|
|
308
371
|
}
|
|
309
372
|
|
|
310
373
|
/**
|
|
@@ -468,12 +531,12 @@ export default class SyncController {
|
|
|
468
531
|
}
|
|
469
532
|
}
|
|
470
533
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
throw new SyncError(
|
|
476
|
-
code: '
|
|
534
|
+
// Validate response shape + resource limits via Zod schema (B64).
|
|
535
|
+
// For HTTP responses, always validate — untrusted boundary.
|
|
536
|
+
const validation = validateSyncResponse(response);
|
|
537
|
+
if (!validation.ok) {
|
|
538
|
+
throw new SyncError(`Invalid sync response: ${validation.error}`, {
|
|
539
|
+
code: 'E_SYNC_PAYLOAD_INVALID',
|
|
477
540
|
});
|
|
478
541
|
}
|
|
479
542
|
|
|
@@ -482,7 +545,7 @@ export default class SyncController {
|
|
|
482
545
|
emit('materialized');
|
|
483
546
|
}
|
|
484
547
|
|
|
485
|
-
const result = this.applySyncResponse(response);
|
|
548
|
+
const result = await this.applySyncResponse(response);
|
|
486
549
|
emit('applied', { applied: result.applied });
|
|
487
550
|
|
|
488
551
|
const durationMs = this._host._clock.now() - attemptStart;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncPayloadSchema -- Zod schemas for sync protocol messages.
|
|
3
|
+
*
|
|
4
|
+
* Validates both shape and resource limits for sync requests and responses
|
|
5
|
+
* at the trust boundary (HTTP ingress/egress). Prevents malformed or
|
|
6
|
+
* oversized payloads from reaching the CRDT merge engine.
|
|
7
|
+
*
|
|
8
|
+
* @module domain/services/SyncPayloadSchema
|
|
9
|
+
* @see B64 -- Sync ingress payload validation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
|
|
14
|
+
// ── Resource Limits ─────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Default resource limits for sync payload validation.
|
|
18
|
+
* Configurable per-deployment via `createSyncResponseSchema(limits)`.
|
|
19
|
+
*
|
|
20
|
+
* @typedef {Object} SyncPayloadLimits
|
|
21
|
+
* @property {number} maxWritersInFrontier - Maximum writers in a frontier object
|
|
22
|
+
* @property {number} maxPatches - Maximum patches in a sync response
|
|
23
|
+
* @property {number} maxOpsPerPatch - Maximum operations per patch
|
|
24
|
+
* @property {number} maxStringBytes - Maximum bytes for string values (writer ID, node ID, etc.)
|
|
25
|
+
* @property {number} maxBlobBytes - Maximum bytes for blob values
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** @type {Readonly<SyncPayloadLimits>} */
|
|
29
|
+
export const DEFAULT_LIMITS = Object.freeze({
|
|
30
|
+
maxWritersInFrontier: 10_000,
|
|
31
|
+
maxPatches: 100_000,
|
|
32
|
+
maxOpsPerPatch: 50_000,
|
|
33
|
+
maxStringBytes: 4096,
|
|
34
|
+
maxBlobBytes: 16 * 1024 * 1024,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ── Schema Version ──────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Current sync protocol schema version.
|
|
41
|
+
* Responses with unknown versions are rejected.
|
|
42
|
+
*/
|
|
43
|
+
export const SYNC_SCHEMA_VERSION = 1;
|
|
44
|
+
|
|
45
|
+
// ── Shared Primitives ───────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Bounded string: rejects strings exceeding maxStringBytes.
|
|
49
|
+
* @param {number} maxBytes
|
|
50
|
+
* @returns {z.ZodString}
|
|
51
|
+
*/
|
|
52
|
+
function boundedString(maxBytes) {
|
|
53
|
+
return z.string().max(maxBytes);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Frontier Schema ─────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Normalizes a frontier value that may be a Map (cbor-x decodes maps)
|
|
60
|
+
* or a plain object into a validated plain object.
|
|
61
|
+
*
|
|
62
|
+
* @param {unknown} value
|
|
63
|
+
* @returns {Record<string, string>|null} Normalized object, or null if invalid
|
|
64
|
+
*/
|
|
65
|
+
export function normalizeFrontier(value) {
|
|
66
|
+
if (value instanceof Map) {
|
|
67
|
+
/** @type {Record<string, string>} */
|
|
68
|
+
const obj = {};
|
|
69
|
+
for (const [k, v] of value) {
|
|
70
|
+
if (typeof k !== 'string' || typeof v !== 'string') {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
obj[k] = v;
|
|
74
|
+
}
|
|
75
|
+
return obj;
|
|
76
|
+
}
|
|
77
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
78
|
+
return /** @type {Record<string, string>} */ (value);
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Creates a frontier schema with a size limit.
|
|
85
|
+
*
|
|
86
|
+
* Frontier values are strings (typically hex SHAs) but we don't enforce
|
|
87
|
+
* hex format here — semantic SHA validation happens at a higher level.
|
|
88
|
+
* This schema validates shape + resource limits only.
|
|
89
|
+
*
|
|
90
|
+
* @param {number} maxWriters
|
|
91
|
+
* @returns {z.ZodType<Record<string, string>>}
|
|
92
|
+
*/
|
|
93
|
+
function frontierSchema(maxWriters) {
|
|
94
|
+
return /** @type {z.ZodType<Record<string, string>>} */ (z.record(
|
|
95
|
+
boundedString(256),
|
|
96
|
+
z.string(),
|
|
97
|
+
).refine(
|
|
98
|
+
(obj) => Object.keys(obj).length <= maxWriters,
|
|
99
|
+
(obj) => ({ message: `Frontier exceeds max writers: ${Object.keys(obj).length} > ${maxWriters}` }),
|
|
100
|
+
));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Op Schema ───────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Minimal op validation — checks for type field and basic shape.
|
|
107
|
+
* Deeper semantic validation happens in JoinReducer/WarpMessageCodec.
|
|
108
|
+
*/
|
|
109
|
+
const opSchema = z.object({
|
|
110
|
+
type: z.string(),
|
|
111
|
+
}).passthrough();
|
|
112
|
+
|
|
113
|
+
// ── Patch Schema ────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Creates a patch schema with ops limit.
|
|
117
|
+
* @param {SyncPayloadLimits} limits
|
|
118
|
+
*/
|
|
119
|
+
function patchSchema(limits) {
|
|
120
|
+
return z.object({
|
|
121
|
+
schema: z.number().int().min(1).optional(),
|
|
122
|
+
writer: boundedString(limits.maxStringBytes).optional(),
|
|
123
|
+
lamport: z.number().int().min(0).optional(),
|
|
124
|
+
ops: z.array(opSchema).max(limits.maxOpsPerPatch),
|
|
125
|
+
context: z.unknown().optional(),
|
|
126
|
+
}).passthrough();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Creates a patches-array entry schema.
|
|
131
|
+
* @param {SyncPayloadLimits} limits
|
|
132
|
+
*/
|
|
133
|
+
function patchEntrySchema(limits) {
|
|
134
|
+
return z.object({
|
|
135
|
+
writerId: boundedString(limits.maxStringBytes),
|
|
136
|
+
sha: z.string(),
|
|
137
|
+
patch: patchSchema(limits),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Sync Request Schema ─────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Creates a validated SyncRequest schema.
|
|
145
|
+
* @param {SyncPayloadLimits} [limits]
|
|
146
|
+
* @returns {z.ZodType}
|
|
147
|
+
*/
|
|
148
|
+
export function createSyncRequestSchema(limits = DEFAULT_LIMITS) {
|
|
149
|
+
return z.object({
|
|
150
|
+
type: z.literal('sync-request'),
|
|
151
|
+
frontier: frontierSchema(limits.maxWritersInFrontier),
|
|
152
|
+
}).strict();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Default SyncRequest schema with default limits */
|
|
156
|
+
export const SyncRequestSchema = createSyncRequestSchema();
|
|
157
|
+
|
|
158
|
+
// ── Sync Response Schema ────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Creates a validated SyncResponse schema.
|
|
162
|
+
* @param {SyncPayloadLimits} [limits]
|
|
163
|
+
* @returns {z.ZodType}
|
|
164
|
+
*/
|
|
165
|
+
export function createSyncResponseSchema(limits = DEFAULT_LIMITS) {
|
|
166
|
+
return z.object({
|
|
167
|
+
type: z.literal('sync-response'),
|
|
168
|
+
frontier: frontierSchema(limits.maxWritersInFrontier),
|
|
169
|
+
patches: z.array(patchEntrySchema(limits)).max(limits.maxPatches),
|
|
170
|
+
}).passthrough();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Default SyncResponse schema with default limits */
|
|
174
|
+
export const SyncResponseSchema = createSyncResponseSchema();
|
|
175
|
+
|
|
176
|
+
// ── Validation Helpers ──────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Validates a sync request payload. Returns the parsed value or throws.
|
|
180
|
+
*
|
|
181
|
+
* Handles Map→object normalization for cbor-x compatibility.
|
|
182
|
+
*
|
|
183
|
+
* @param {unknown} payload - Raw parsed payload (from JSON.parse or cbor-x decode)
|
|
184
|
+
* @param {SyncPayloadLimits} [limits] - Resource limits
|
|
185
|
+
* @returns {{ ok: true, value: { type: 'sync-request', frontier: Record<string, string> } } | { ok: false, error: string }}
|
|
186
|
+
*/
|
|
187
|
+
export function validateSyncRequest(payload, limits = DEFAULT_LIMITS) {
|
|
188
|
+
// Normalize Map frontier from cbor-x
|
|
189
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
190
|
+
const p = /** @type {Record<string, unknown>} */ (payload);
|
|
191
|
+
if (p.frontier instanceof Map) {
|
|
192
|
+
const normalized = normalizeFrontier(p.frontier);
|
|
193
|
+
if (!normalized) {
|
|
194
|
+
return { ok: false, error: 'Invalid frontier: Map contains non-string entries' };
|
|
195
|
+
}
|
|
196
|
+
p.frontier = normalized;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const schema = limits === DEFAULT_LIMITS ? SyncRequestSchema : createSyncRequestSchema(limits);
|
|
201
|
+
const result = schema.safeParse(payload);
|
|
202
|
+
if (!result.success) {
|
|
203
|
+
return { ok: false, error: result.error.message };
|
|
204
|
+
}
|
|
205
|
+
return { ok: true, value: /** @type {{ type: 'sync-request', frontier: Record<string, string> }} */ (result.data) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Validates a sync response payload. Returns the parsed value or an error.
|
|
210
|
+
*
|
|
211
|
+
* Handles Map→object normalization for cbor-x compatibility.
|
|
212
|
+
*
|
|
213
|
+
* @param {unknown} payload - Raw parsed payload
|
|
214
|
+
* @param {SyncPayloadLimits} [limits] - Resource limits
|
|
215
|
+
* @returns {{ ok: true, value: unknown } | { ok: false, error: string }}
|
|
216
|
+
*/
|
|
217
|
+
export function validateSyncResponse(payload, limits = DEFAULT_LIMITS) {
|
|
218
|
+
// Normalize Map frontier from cbor-x
|
|
219
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
220
|
+
const p = /** @type {Record<string, unknown>} */ (payload);
|
|
221
|
+
if (p.frontier instanceof Map) {
|
|
222
|
+
const normalized = normalizeFrontier(p.frontier);
|
|
223
|
+
if (!normalized) {
|
|
224
|
+
return { ok: false, error: 'Invalid frontier: Map contains non-string entries' };
|
|
225
|
+
}
|
|
226
|
+
p.frontier = normalized;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const schema = limits === DEFAULT_LIMITS ? SyncResponseSchema : createSyncResponseSchema(limits);
|
|
231
|
+
const result = schema.safeParse(payload);
|
|
232
|
+
if (!result.success) {
|
|
233
|
+
return { ok: false, error: result.error.message };
|
|
234
|
+
}
|
|
235
|
+
return { ok: true, value: result.data };
|
|
236
|
+
}
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
39
|
import defaultCodec from '../utils/defaultCodec.js';
|
|
40
|
+
import nullLogger from '../utils/nullLogger.js';
|
|
40
41
|
import { decodePatchMessage, assertOpsCompatible, SCHEMA_V3 } from './WarpMessageCodec.js';
|
|
41
42
|
import { join, cloneStateV5 } from './JoinReducer.js';
|
|
42
43
|
import { cloneFrontier, updateFrontier } from './Frontier.js';
|
|
@@ -267,11 +268,9 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
|
|
|
267
268
|
newWritersForRemote.push(writerId);
|
|
268
269
|
} else if (remoteSha !== localSha) {
|
|
269
270
|
// Different heads - remote might need patches from its head to local head
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
needFromLocal.set(writerId, { from: remoteSha, to: localSha });
|
|
274
|
-
}
|
|
271
|
+
// Always add both directions — ancestry is verified during loadPatchRange()
|
|
272
|
+
// which will throw E_SYNC_DIVERGENCE if neither side descends from the other (S3)
|
|
273
|
+
needFromLocal.set(writerId, { from: remoteSha, to: localSha });
|
|
275
274
|
}
|
|
276
275
|
}
|
|
277
276
|
|
|
@@ -315,6 +314,8 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
|
|
|
315
314
|
* - `writerId`: The writer who created this patch
|
|
316
315
|
* - `sha`: The commit SHA this patch came from (for frontier updates)
|
|
317
316
|
* - `patch`: The decoded patch object with ops and context
|
|
317
|
+
* @property {Array<{writerId: string, reason: string, localSha: string, remoteSha: string|null}>} [skippedWriters] - Writers that were skipped during sync
|
|
318
|
+
* (e.g. due to trust gate filtering, divergence, or missing refs)
|
|
318
319
|
*/
|
|
319
320
|
|
|
320
321
|
/**
|
|
@@ -375,6 +376,7 @@ export function createSyncRequest(frontier) {
|
|
|
375
376
|
* @param {string} graphName - Graph name for error messages and logging
|
|
376
377
|
* @param {Object} [options]
|
|
377
378
|
* @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for deserialization
|
|
379
|
+
* @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger for divergence warnings
|
|
378
380
|
* @returns {Promise<SyncResponse>} Response containing local frontier and patches.
|
|
379
381
|
* Patches are ordered chronologically within each writer.
|
|
380
382
|
* @throws {Error} If patch loading fails for reasons other than divergence
|
|
@@ -388,7 +390,9 @@ export function createSyncRequest(frontier) {
|
|
|
388
390
|
* res.json(response);
|
|
389
391
|
* });
|
|
390
392
|
*/
|
|
391
|
-
export async function processSyncRequest(request, localFrontier, persistence, graphName, { codec } = /** @type {{ codec?: import('../../ports/CodecPort.js').default }} */ ({})) {
|
|
393
|
+
export async function processSyncRequest(request, localFrontier, persistence, graphName, { codec, logger } = /** @type {{ codec?: import('../../ports/CodecPort.js').default, logger?: import('../../ports/LoggerPort.js').default }} */ ({})) {
|
|
394
|
+
const log = logger || nullLogger;
|
|
395
|
+
|
|
392
396
|
// Convert incoming frontier from object to Map
|
|
393
397
|
const remoteFrontier = new Map(Object.entries(request.frontier));
|
|
394
398
|
|
|
@@ -397,6 +401,8 @@ export async function processSyncRequest(request, localFrontier, persistence, gr
|
|
|
397
401
|
|
|
398
402
|
// Load patches that the requester needs (from local to requester)
|
|
399
403
|
const patches = [];
|
|
404
|
+
/** @type {Array<{writerId: string, reason: string, localSha: string, remoteSha: string|null}>} */
|
|
405
|
+
const skippedWriters = [];
|
|
400
406
|
|
|
401
407
|
for (const [writerId, range] of delta.needFromRemote) {
|
|
402
408
|
try {
|
|
@@ -413,9 +419,21 @@ export async function processSyncRequest(request, localFrontier, persistence, gr
|
|
|
413
419
|
patches.push({ writerId, sha, patch });
|
|
414
420
|
}
|
|
415
421
|
} catch (err) {
|
|
416
|
-
// If we detect divergence, skip this writer
|
|
417
|
-
// The requester
|
|
422
|
+
// If we detect divergence, log and skip this writer (B65).
|
|
423
|
+
// The requester will not receive patches for this writer.
|
|
418
424
|
if ((err instanceof Error && 'code' in err && /** @type {{ code: string }} */ (err).code === 'E_SYNC_DIVERGENCE') || (err instanceof Error && err.message?.includes('Divergence detected'))) {
|
|
425
|
+
const entry = {
|
|
426
|
+
writerId,
|
|
427
|
+
reason: 'E_SYNC_DIVERGENCE',
|
|
428
|
+
localSha: range.to,
|
|
429
|
+
remoteSha: range.from ?? '',
|
|
430
|
+
};
|
|
431
|
+
skippedWriters.push(entry);
|
|
432
|
+
log.warn('Sync divergence detected — skipping writer', {
|
|
433
|
+
code: 'E_SYNC_DIVERGENCE',
|
|
434
|
+
graphName,
|
|
435
|
+
...entry,
|
|
436
|
+
});
|
|
419
437
|
continue;
|
|
420
438
|
}
|
|
421
439
|
throw err;
|
|
@@ -433,6 +451,7 @@ export async function processSyncRequest(request, localFrontier, persistence, gr
|
|
|
433
451
|
type: /** @type {'sync-response'} */ ('sync-response'),
|
|
434
452
|
frontier: frontierObj,
|
|
435
453
|
patches,
|
|
454
|
+
skippedWriters,
|
|
436
455
|
};
|
|
437
456
|
}
|
|
438
457
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncTrustGate -- Encapsulates trust evaluation for sync operations.
|
|
3
|
+
*
|
|
4
|
+
* Evaluates whether inbound patch authors are trusted according to the
|
|
5
|
+
* trust record chain. Used by SyncController to validate HTTP sync
|
|
6
|
+
* responses before applying patches.
|
|
7
|
+
*
|
|
8
|
+
* Trust-gates on `writersApplied` (patch authors being ingested), not
|
|
9
|
+
* frontier keys (which are claims, not effects).
|
|
10
|
+
*
|
|
11
|
+
* @module domain/services/SyncTrustGate
|
|
12
|
+
* @see B1 -- Signed sync ingress
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import nullLogger from '../utils/nullLogger.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {'enforce'|'log-only'|'off'} TrustMode
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} TrustGateResult
|
|
23
|
+
* @property {boolean} allowed - Whether the writers are trusted
|
|
24
|
+
* @property {string[]} untrustedWriters - Writers that failed trust evaluation
|
|
25
|
+
* @property {string} verdict - Human-readable verdict
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** @type {() => TrustGateResult} */
|
|
29
|
+
const PASS = () => ({ allowed: true, untrustedWriters: [], verdict: 'pass' });
|
|
30
|
+
|
|
31
|
+
export default class SyncTrustGate {
|
|
32
|
+
/**
|
|
33
|
+
* @param {Object} options
|
|
34
|
+
* @param {{evaluateWriters: (writerIds: string[]) => Promise<{trusted: Set<string>}>}} [options.trustEvaluator] - Trust evaluator instance
|
|
35
|
+
* @param {TrustMode} [options.trustMode='off'] - Trust enforcement mode
|
|
36
|
+
* @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger
|
|
37
|
+
*/
|
|
38
|
+
constructor({ trustEvaluator, trustMode = 'off', logger } = {}) {
|
|
39
|
+
this._evaluator = trustEvaluator || null;
|
|
40
|
+
this._mode = trustMode;
|
|
41
|
+
this._logger = logger || nullLogger;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Evaluates whether the given patch writers are trusted.
|
|
46
|
+
*
|
|
47
|
+
* @param {string[]} writerIds - Writer IDs from patches being applied
|
|
48
|
+
* @param {Object} [context] - Additional context for logging
|
|
49
|
+
* @param {string} [context.graphName] - Graph name
|
|
50
|
+
* @param {string} [context.peerId] - Remote peer identity (if authenticated)
|
|
51
|
+
* @returns {Promise<TrustGateResult>}
|
|
52
|
+
*/
|
|
53
|
+
async evaluate(writerIds, context = {}) {
|
|
54
|
+
if (this._mode === 'off' || !this._evaluator) {
|
|
55
|
+
return { allowed: true, untrustedWriters: [], verdict: 'trust_disabled' };
|
|
56
|
+
}
|
|
57
|
+
if (writerIds.length === 0) {
|
|
58
|
+
return { allowed: true, untrustedWriters: [], verdict: 'no_writers' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const result = await this._evaluator.evaluateWriters(writerIds);
|
|
63
|
+
const untrusted = writerIds.filter((id) => !result.trusted.has(id));
|
|
64
|
+
return this._decide(untrusted, writerIds, context);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return this._handleError(err, writerIds, context);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Decides the gate result based on untrusted writers and mode.
|
|
72
|
+
* @param {string[]} untrusted
|
|
73
|
+
* @param {string[]} writerIds
|
|
74
|
+
* @param {Object} context
|
|
75
|
+
* @returns {TrustGateResult}
|
|
76
|
+
* @private
|
|
77
|
+
*/
|
|
78
|
+
_decide(untrusted, writerIds, context) {
|
|
79
|
+
this._logger.info('Trust gate decision', {
|
|
80
|
+
code: 'SYNC_TRUST_GATE',
|
|
81
|
+
mode: this._mode,
|
|
82
|
+
writersApplied: writerIds,
|
|
83
|
+
untrustedWriters: untrusted,
|
|
84
|
+
verdict: untrusted.length === 0 ? 'pass' : 'fail',
|
|
85
|
+
...context,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (untrusted.length === 0) {
|
|
89
|
+
return PASS();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (this._mode === 'enforce') {
|
|
93
|
+
this._logger.warn('Trust gate rejected untrusted writers', {
|
|
94
|
+
code: 'SYNC_TRUST_REJECTED',
|
|
95
|
+
untrustedWriters: untrusted,
|
|
96
|
+
...context,
|
|
97
|
+
});
|
|
98
|
+
return { allowed: false, untrustedWriters: untrusted, verdict: 'rejected' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this._logger.warn('Trust gate: untrusted writers allowed (log-only mode)', {
|
|
102
|
+
code: 'SYNC_TRUST_WARN',
|
|
103
|
+
untrustedWriters: untrusted,
|
|
104
|
+
...context,
|
|
105
|
+
});
|
|
106
|
+
return { allowed: true, untrustedWriters: untrusted, verdict: 'warn_allowed' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Handles trust evaluation errors with fail-open/fail-closed semantics.
|
|
111
|
+
* @param {unknown} err
|
|
112
|
+
* @param {string[]} writerIds
|
|
113
|
+
* @param {Object} context
|
|
114
|
+
* @returns {TrustGateResult}
|
|
115
|
+
* @private
|
|
116
|
+
*/
|
|
117
|
+
_handleError(err, writerIds, context) {
|
|
118
|
+
this._logger.error('Trust gate evaluation failed', {
|
|
119
|
+
code: 'SYNC_TRUST_ERROR',
|
|
120
|
+
error: err instanceof Error ? err.message : String(err),
|
|
121
|
+
...context,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (this._mode === 'enforce') {
|
|
125
|
+
return { allowed: false, untrustedWriters: writerIds, verdict: 'error_rejected' };
|
|
126
|
+
}
|
|
127
|
+
return { allowed: true, untrustedWriters: [], verdict: 'error_allowed' };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Extracts writer IDs from patches in a sync response.
|
|
132
|
+
* These are the actual data authors being ingested — the trust target.
|
|
133
|
+
*
|
|
134
|
+
* @param {Array<{writerId: string}>} patches - Patches from sync response
|
|
135
|
+
* @returns {string[]} Deduplicated writer IDs
|
|
136
|
+
*/
|
|
137
|
+
static extractWritersFromPatches(patches) {
|
|
138
|
+
const writers = new Set();
|
|
139
|
+
for (const { writerId } of patches) {
|
|
140
|
+
if (writerId) {
|
|
141
|
+
writers.add(writerId);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return [...writers];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -182,10 +182,10 @@ function computePropLoss(state, { nodesA, nodesBSet, configA, configB }) {
|
|
|
182
182
|
*/
|
|
183
183
|
export function computeTranslationCost(configA, configB, state) {
|
|
184
184
|
/** @param {unknown} m */
|
|
185
|
-
const isValidMatch = (m) => typeof m === 'string' || (Array.isArray(m) && m.every(/** @param {unknown} i */ i => typeof i === 'string'));
|
|
185
|
+
const isValidMatch = (m) => typeof m === 'string' || (Array.isArray(m) && m.length > 0 && m.every(/** @param {unknown} i */ i => typeof i === 'string'));
|
|
186
186
|
if (!configA || !isValidMatch(configA.match) ||
|
|
187
187
|
!configB || !isValidMatch(configB.match)) {
|
|
188
|
-
throw new Error('configA.match and configB.match must be strings or arrays of strings');
|
|
188
|
+
throw new Error('configA.match and configB.match must be non-empty strings or non-empty arrays of strings');
|
|
189
189
|
}
|
|
190
190
|
const allNodes = [...orsetElements(state.nodeAlive)];
|
|
191
191
|
const nodesA = allNodes.filter((id) => matchGlob(configA.match, id));
|