@git-stunts/git-warp 12.0.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 +6 -9
- package/bin/warp-graph.js +6 -2
- package/index.d.ts +4 -4
- package/package.json +2 -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/ObserverView.js +4 -32
- package/src/domain/services/PatchBuilderV2.js +29 -3
- package/src/domain/services/QueryBuilder.js +78 -74
- 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 +8 -24
- package/src/domain/trust/TrustRecordService.js +119 -6
- package/src/domain/utils/matchGlob.js +51 -0
- 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 +7 -5
- package/src/domain/warp/subscribe.methods.js +11 -19
- package/src/infrastructure/adapters/GitGraphAdapter.js +2 -2
|
@@ -5,9 +5,31 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import QueryError from '../errors/QueryError.js';
|
|
8
|
+
import { matchGlob } from '../utils/matchGlob.js';
|
|
8
9
|
|
|
9
10
|
const DEFAULT_PATTERN = '*';
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Processes items in batches with bounded concurrency.
|
|
14
|
+
*
|
|
15
|
+
* @template T, R
|
|
16
|
+
* @param {T[]} items - Items to process
|
|
17
|
+
* @param {(item: T) => Promise<R>} fn - Async function to apply to each item
|
|
18
|
+
* @param {number} [limit=100] - Maximum concurrent operations per batch
|
|
19
|
+
* @returns {Promise<R[]>} Results in input order
|
|
20
|
+
*/
|
|
21
|
+
async function batchMap(items, fn, limit = 100) {
|
|
22
|
+
const results = [];
|
|
23
|
+
for (let i = 0; i < items.length; i += limit) {
|
|
24
|
+
const batch = items.slice(i, i + limit);
|
|
25
|
+
const batchResults = await Promise.all(batch.map(fn));
|
|
26
|
+
for (const r of batchResults) {
|
|
27
|
+
results.push(r);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return results;
|
|
31
|
+
}
|
|
32
|
+
|
|
11
33
|
/**
|
|
12
34
|
* @typedef {Object} QueryNodeSnapshot
|
|
13
35
|
* @property {string} id - The unique identifier of the node
|
|
@@ -48,15 +70,18 @@ const DEFAULT_PATTERN = '*';
|
|
|
48
70
|
*/
|
|
49
71
|
|
|
50
72
|
/**
|
|
51
|
-
* Asserts that a match pattern is a string.
|
|
73
|
+
* Asserts that a match pattern is a string or array of strings.
|
|
52
74
|
*
|
|
53
75
|
* @param {unknown} pattern - The pattern to validate
|
|
54
|
-
* @throws {QueryError} If pattern is not a string (code: E_QUERY_MATCH_TYPE)
|
|
76
|
+
* @throws {QueryError} If pattern is not a string or array of strings (code: E_QUERY_MATCH_TYPE)
|
|
55
77
|
* @private
|
|
56
78
|
*/
|
|
57
79
|
function assertMatchPattern(pattern) {
|
|
58
|
-
|
|
59
|
-
|
|
80
|
+
const isString = typeof pattern === 'string';
|
|
81
|
+
const isStringArray = Array.isArray(pattern) && pattern.every((p) => typeof p === 'string');
|
|
82
|
+
|
|
83
|
+
if (!isString && !isStringArray) {
|
|
84
|
+
throw new QueryError('match() expects a string pattern or array of string patterns', {
|
|
60
85
|
code: 'E_QUERY_MATCH_TYPE',
|
|
61
86
|
context: { receivedType: typeof pattern },
|
|
62
87
|
});
|
|
@@ -165,41 +190,6 @@ function sortIds(ids) {
|
|
|
165
190
|
return [...ids].sort();
|
|
166
191
|
}
|
|
167
192
|
|
|
168
|
-
/**
|
|
169
|
-
* Escapes special regex characters in a string so it can be used as a literal match.
|
|
170
|
-
*
|
|
171
|
-
* @param {string} value - The string to escape
|
|
172
|
-
* @returns {string} The escaped string safe for use in a RegExp
|
|
173
|
-
* @private
|
|
174
|
-
*/
|
|
175
|
-
function escapeRegex(value) {
|
|
176
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Tests whether a node ID matches a glob-style pattern.
|
|
181
|
-
*
|
|
182
|
-
* Supports:
|
|
183
|
-
* - `*` as the default pattern, matching all node IDs
|
|
184
|
-
* - Wildcard `*` anywhere in the pattern, matching zero or more characters
|
|
185
|
-
* - Literal match when pattern contains no wildcards
|
|
186
|
-
*
|
|
187
|
-
* @param {string} nodeId - The node ID to test
|
|
188
|
-
* @param {string} pattern - The glob pattern (e.g., "user:*", "*:admin", "*")
|
|
189
|
-
* @returns {boolean} True if the node ID matches the pattern
|
|
190
|
-
* @private
|
|
191
|
-
*/
|
|
192
|
-
function matchesPattern(nodeId, pattern) {
|
|
193
|
-
if (pattern === DEFAULT_PATTERN) {
|
|
194
|
-
return true;
|
|
195
|
-
}
|
|
196
|
-
if (pattern.includes('*')) {
|
|
197
|
-
const regex = new RegExp(`^${escapeRegex(pattern).replace(/\\\*/g, '.*')}$`);
|
|
198
|
-
return regex.test(nodeId);
|
|
199
|
-
}
|
|
200
|
-
return nodeId === pattern;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
193
|
/**
|
|
204
194
|
* Recursively freezes an object and all nested objects/arrays.
|
|
205
195
|
*
|
|
@@ -494,7 +484,7 @@ export default class QueryBuilder {
|
|
|
494
484
|
*/
|
|
495
485
|
constructor(graph) {
|
|
496
486
|
this._graph = graph;
|
|
497
|
-
/** @type {string|null} */
|
|
487
|
+
/** @type {string|string[]|null} */
|
|
498
488
|
this._pattern = null;
|
|
499
489
|
/** @type {Array<{type: string, fn?: (node: QueryNodeSnapshot) => boolean, label?: string, depth?: [number, number]}>} */
|
|
500
490
|
this._operations = [];
|
|
@@ -505,19 +495,21 @@ export default class QueryBuilder {
|
|
|
505
495
|
}
|
|
506
496
|
|
|
507
497
|
/**
|
|
508
|
-
* Sets the match pattern for filtering nodes by ID.
|
|
498
|
+
* Sets the match pattern(s) for filtering nodes by ID.
|
|
509
499
|
*
|
|
510
500
|
* Supports glob-style patterns:
|
|
511
501
|
* - `*` matches all nodes
|
|
512
502
|
* - `user:*` matches all nodes starting with "user:"
|
|
513
503
|
* - `*:admin` matches all nodes ending with ":admin"
|
|
504
|
+
* - Array of patterns: `['campaign:*', 'milestone:*']` (OR semantics)
|
|
514
505
|
*
|
|
515
|
-
* @param {string} pattern - Glob pattern to match node IDs against
|
|
506
|
+
* @param {string|string[]} pattern - Glob pattern or array of patterns to match node IDs against
|
|
516
507
|
* @returns {QueryBuilder} This builder for chaining
|
|
517
|
-
* @throws {QueryError} If pattern is not a string (code: E_QUERY_MATCH_TYPE)
|
|
508
|
+
* @throws {QueryError} If pattern is not a string or array of strings (code: E_QUERY_MATCH_TYPE)
|
|
518
509
|
*/
|
|
519
510
|
match(pattern) {
|
|
520
511
|
assertMatchPattern(pattern);
|
|
512
|
+
/** @type {string|string[]|null} */
|
|
521
513
|
this._pattern = pattern;
|
|
522
514
|
return this;
|
|
523
515
|
}
|
|
@@ -681,22 +673,33 @@ export default class QueryBuilder {
|
|
|
681
673
|
|
|
682
674
|
const pattern = this._pattern ?? DEFAULT_PATTERN;
|
|
683
675
|
|
|
676
|
+
// Per-run props memo to avoid redundant getNodeProps calls
|
|
677
|
+
/** @type {Map<string, Map<string, unknown>>} */
|
|
678
|
+
const propsMemo = new Map();
|
|
679
|
+
const getProps = async (/** @type {string} */ nodeId) => {
|
|
680
|
+
const cached = propsMemo.get(nodeId);
|
|
681
|
+
if (cached !== undefined) {
|
|
682
|
+
return cached;
|
|
683
|
+
}
|
|
684
|
+
const propsMap = (await this._graph.getNodeProps(nodeId)) || new Map();
|
|
685
|
+
propsMemo.set(nodeId, propsMap);
|
|
686
|
+
return propsMap;
|
|
687
|
+
};
|
|
688
|
+
|
|
684
689
|
let workingSet;
|
|
685
|
-
workingSet = allNodes.filter((nodeId) =>
|
|
690
|
+
workingSet = allNodes.filter((nodeId) => matchGlob(pattern, nodeId));
|
|
686
691
|
|
|
687
692
|
for (const op of this._operations) {
|
|
688
693
|
if (op.type === 'where') {
|
|
689
|
-
const snapshots = await
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
})
|
|
699
|
-
);
|
|
694
|
+
const snapshots = await batchMap(workingSet, async (nodeId) => {
|
|
695
|
+
const propsMap = await getProps(nodeId);
|
|
696
|
+
const edgesOut = adjacency.outgoing.get(nodeId) || [];
|
|
697
|
+
const edgesIn = adjacency.incoming.get(nodeId) || [];
|
|
698
|
+
return {
|
|
699
|
+
nodeId,
|
|
700
|
+
snapshot: createNodeSnapshot({ id: nodeId, propsMap, edgesOut, edgesIn }),
|
|
701
|
+
};
|
|
702
|
+
});
|
|
700
703
|
const predicate = /** @type {(node: QueryNodeSnapshot) => boolean} */ (op.fn);
|
|
701
704
|
const filtered = snapshots
|
|
702
705
|
.filter(({ snapshot }) => predicate(snapshot))
|
|
@@ -727,7 +730,7 @@ export default class QueryBuilder {
|
|
|
727
730
|
}
|
|
728
731
|
|
|
729
732
|
if (this._aggregate) {
|
|
730
|
-
return await this._runAggregate(workingSet, stateHash);
|
|
733
|
+
return await this._runAggregate(workingSet, stateHash, getProps);
|
|
731
734
|
}
|
|
732
735
|
|
|
733
736
|
const selected = this._select;
|
|
@@ -747,22 +750,20 @@ export default class QueryBuilder {
|
|
|
747
750
|
const includeId = !selectFields || selectFields.includes('id');
|
|
748
751
|
const includeProps = !selectFields || selectFields.includes('props');
|
|
749
752
|
|
|
750
|
-
const nodes = await
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
entry.props = props;
|
|
761
|
-
}
|
|
753
|
+
const nodes = await batchMap(workingSet, async (nodeId) => {
|
|
754
|
+
const entry = {};
|
|
755
|
+
if (includeId) {
|
|
756
|
+
entry.id = nodeId;
|
|
757
|
+
}
|
|
758
|
+
if (includeProps) {
|
|
759
|
+
const propsMap = await getProps(nodeId);
|
|
760
|
+
const props = buildPropsSnapshot(propsMap);
|
|
761
|
+
if (selectFields || Object.keys(props).length > 0) {
|
|
762
|
+
entry.props = props;
|
|
762
763
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
);
|
|
764
|
+
}
|
|
765
|
+
return entry;
|
|
766
|
+
});
|
|
766
767
|
|
|
767
768
|
return { stateHash, nodes };
|
|
768
769
|
}
|
|
@@ -776,10 +777,11 @@ export default class QueryBuilder {
|
|
|
776
777
|
*
|
|
777
778
|
* @param {string[]} workingSet - Array of matched node IDs
|
|
778
779
|
* @param {string} stateHash - Hash of the materialized state
|
|
780
|
+
* @param {(nodeId: string) => Promise<Map<string, unknown>>} getProps - Memoized props fetcher
|
|
779
781
|
* @returns {Promise<AggregateResult>} Object containing stateHash and requested aggregation values
|
|
780
782
|
* @private
|
|
781
783
|
*/
|
|
782
|
-
async _runAggregate(workingSet, stateHash) {
|
|
784
|
+
async _runAggregate(workingSet, stateHash, getProps) {
|
|
783
785
|
const spec = /** @type {AggregateSpec} */ (this._aggregate);
|
|
784
786
|
/** @type {AggregateResult} */
|
|
785
787
|
const result = { stateHash };
|
|
@@ -802,8 +804,10 @@ export default class QueryBuilder {
|
|
|
802
804
|
});
|
|
803
805
|
}
|
|
804
806
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
+
// Pre-fetch all props with bounded concurrency
|
|
808
|
+
const propsList = await batchMap(workingSet, getProps);
|
|
809
|
+
|
|
810
|
+
for (const propsMap of propsList) {
|
|
807
811
|
for (const { segments, values } of propsByAgg.values()) {
|
|
808
812
|
/** @type {unknown} */
|
|
809
813
|
let value = propsMap.get(segments[0]);
|
|
@@ -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
|
+
}
|