@git-stunts/git-warp 10.3.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/README.md +6 -3
- package/SECURITY.md +89 -1
- package/bin/warp-graph.js +574 -208
- package/index.d.ts +55 -0
- package/index.js +4 -0
- package/package.json +8 -4
- package/src/domain/WarpGraph.js +334 -161
- package/src/domain/crdt/LWW.js +1 -1
- package/src/domain/crdt/ORSet.js +10 -6
- package/src/domain/crdt/VersionVector.js +5 -1
- package/src/domain/errors/EmptyMessageError.js +2 -4
- package/src/domain/errors/ForkError.js +4 -0
- package/src/domain/errors/IndexError.js +4 -0
- package/src/domain/errors/OperationAbortedError.js +4 -0
- package/src/domain/errors/QueryError.js +4 -0
- package/src/domain/errors/SchemaUnsupportedError.js +4 -0
- package/src/domain/errors/ShardCorruptionError.js +2 -6
- package/src/domain/errors/ShardLoadError.js +2 -6
- package/src/domain/errors/ShardValidationError.js +2 -7
- package/src/domain/errors/StorageError.js +2 -6
- package/src/domain/errors/SyncError.js +4 -0
- package/src/domain/errors/TraversalError.js +4 -0
- package/src/domain/errors/WarpError.js +2 -4
- package/src/domain/errors/WormholeError.js +4 -0
- package/src/domain/services/AnchorMessageCodec.js +1 -4
- package/src/domain/services/BitmapIndexBuilder.js +10 -6
- package/src/domain/services/BitmapIndexReader.js +27 -21
- package/src/domain/services/BoundaryTransitionRecord.js +22 -15
- package/src/domain/services/CheckpointMessageCodec.js +1 -7
- package/src/domain/services/CheckpointSerializerV5.js +20 -19
- package/src/domain/services/CheckpointService.js +18 -18
- package/src/domain/services/CommitDagTraversalService.js +13 -1
- package/src/domain/services/DagPathFinding.js +40 -18
- package/src/domain/services/DagTopology.js +7 -6
- package/src/domain/services/DagTraversal.js +5 -3
- package/src/domain/services/Frontier.js +7 -6
- package/src/domain/services/HealthCheckService.js +15 -14
- package/src/domain/services/HookInstaller.js +64 -13
- package/src/domain/services/HttpSyncServer.js +88 -19
- package/src/domain/services/IndexRebuildService.js +12 -12
- package/src/domain/services/IndexStalenessChecker.js +13 -6
- package/src/domain/services/JoinReducer.js +28 -27
- package/src/domain/services/LogicalTraversal.js +7 -6
- package/src/domain/services/MessageCodecInternal.js +2 -0
- package/src/domain/services/ObserverView.js +6 -6
- package/src/domain/services/PatchBuilderV2.js +9 -9
- package/src/domain/services/PatchMessageCodec.js +1 -7
- package/src/domain/services/ProvenanceIndex.js +6 -8
- package/src/domain/services/ProvenancePayload.js +1 -2
- package/src/domain/services/QueryBuilder.js +29 -23
- package/src/domain/services/StateDiff.js +7 -7
- package/src/domain/services/StateSerializerV5.js +8 -6
- package/src/domain/services/StreamingBitmapIndexBuilder.js +29 -23
- package/src/domain/services/SyncAuthService.js +396 -0
- package/src/domain/services/SyncProtocol.js +23 -26
- package/src/domain/services/TemporalQuery.js +4 -3
- package/src/domain/services/TranslationCost.js +4 -4
- package/src/domain/services/WormholeService.js +19 -15
- package/src/domain/types/TickReceipt.js +10 -6
- package/src/domain/types/WarpTypesV2.js +2 -3
- package/src/domain/utils/CachedValue.js +1 -1
- package/src/domain/utils/LRUCache.js +3 -3
- package/src/domain/utils/MinHeap.js +2 -2
- package/src/domain/utils/RefLayout.js +19 -0
- package/src/domain/utils/WriterId.js +2 -2
- package/src/domain/utils/defaultCodec.js +9 -2
- package/src/domain/utils/defaultCrypto.js +36 -0
- package/src/domain/utils/roaring.js +5 -5
- package/src/domain/utils/seekCacheKey.js +32 -0
- package/src/domain/warp/PatchSession.js +3 -3
- package/src/domain/warp/Writer.js +2 -2
- package/src/infrastructure/adapters/BunHttpAdapter.js +21 -8
- package/src/infrastructure/adapters/CasSeekCacheAdapter.js +311 -0
- package/src/infrastructure/adapters/ClockAdapter.js +2 -2
- package/src/infrastructure/adapters/DenoHttpAdapter.js +22 -9
- package/src/infrastructure/adapters/GitGraphAdapter.js +25 -83
- package/src/infrastructure/adapters/InMemoryGraphAdapter.js +488 -0
- package/src/infrastructure/adapters/NodeCryptoAdapter.js +16 -3
- package/src/infrastructure/adapters/NodeHttpAdapter.js +33 -11
- package/src/infrastructure/adapters/WebCryptoAdapter.js +21 -11
- package/src/infrastructure/adapters/adapterValidation.js +90 -0
- package/src/infrastructure/codecs/CborCodec.js +16 -8
- package/src/ports/BlobPort.js +2 -2
- package/src/ports/CodecPort.js +2 -2
- package/src/ports/CommitPort.js +8 -21
- package/src/ports/ConfigPort.js +3 -3
- package/src/ports/CryptoPort.js +7 -7
- package/src/ports/GraphPersistencePort.js +12 -14
- package/src/ports/HttpServerPort.js +1 -5
- package/src/ports/IndexStoragePort.js +1 -0
- package/src/ports/LoggerPort.js +9 -9
- package/src/ports/RefPort.js +5 -5
- package/src/ports/SeekCachePort.js +73 -0
- package/src/ports/TreePort.js +3 -3
- package/src/visualization/layouts/converters.js +14 -7
- package/src/visualization/layouts/elkAdapter.js +17 -4
- package/src/visualization/layouts/elkLayout.js +23 -7
- package/src/visualization/layouts/index.js +3 -3
- package/src/visualization/renderers/ascii/check.js +30 -17
- package/src/visualization/renderers/ascii/graph.js +92 -1
- package/src/visualization/renderers/ascii/history.js +28 -26
- package/src/visualization/renderers/ascii/info.js +9 -7
- package/src/visualization/renderers/ascii/materialize.js +20 -16
- package/src/visualization/renderers/ascii/opSummary.js +15 -7
- package/src/visualization/renderers/ascii/path.js +1 -1
- package/src/visualization/renderers/ascii/seek.js +187 -23
- package/src/visualization/renderers/ascii/table.js +1 -1
- package/src/visualization/renderers/svg/index.js +5 -1
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HMAC-SHA256 request signing and verification for the sync protocol.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - Canonical payload construction
|
|
6
|
+
* - Request signing (client side)
|
|
7
|
+
* - Request verification with replay protection (server side)
|
|
8
|
+
*
|
|
9
|
+
* @module domain/services/SyncAuthService
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import LRUCache from '../utils/LRUCache.js';
|
|
13
|
+
import defaultCrypto from '../utils/defaultCrypto.js';
|
|
14
|
+
import nullLogger from '../utils/nullLogger.js';
|
|
15
|
+
|
|
16
|
+
const SIG_VERSION = '1';
|
|
17
|
+
const SIG_PREFIX = 'warp-v1';
|
|
18
|
+
const HMAC_ALGO = 'sha256';
|
|
19
|
+
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
20
|
+
const DEFAULT_NONCE_CAPACITY = 100_000;
|
|
21
|
+
const NONCE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
22
|
+
const SIG_HEX_LENGTH = 64;
|
|
23
|
+
const HEX_PATTERN = /^[0-9a-f]+$/;
|
|
24
|
+
const MAX_TIMESTAMP_DIGITS = 16;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Canonicalizes a URL path for signature computation.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} url - URL or path to canonicalize
|
|
30
|
+
* @returns {string} Canonical path (pathname + search, no fragment)
|
|
31
|
+
*/
|
|
32
|
+
export function canonicalizePath(url) {
|
|
33
|
+
const parsed = new URL(url, 'http://localhost');
|
|
34
|
+
return parsed.pathname + (parsed.search || '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Builds the canonical string that gets signed.
|
|
39
|
+
*
|
|
40
|
+
* @param {Object} params
|
|
41
|
+
* @param {string} params.keyId - Key identifier
|
|
42
|
+
* @param {string} params.method - HTTP method (uppercased by caller)
|
|
43
|
+
* @param {string} params.path - Canonical path
|
|
44
|
+
* @param {string} params.timestamp - Epoch milliseconds as string
|
|
45
|
+
* @param {string} params.nonce - UUIDv4 nonce
|
|
46
|
+
* @param {string} params.contentType - Content-Type header value
|
|
47
|
+
* @param {string} params.bodySha256 - Hex SHA-256 of request body
|
|
48
|
+
* @returns {string} Pipe-delimited canonical payload
|
|
49
|
+
*/
|
|
50
|
+
export function buildCanonicalPayload({ keyId, method, path, timestamp, nonce, contentType, bodySha256 }) {
|
|
51
|
+
return `${SIG_PREFIX}|${keyId}|${method}|${path}|${timestamp}|${nonce}|${contentType}|${bodySha256}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Signs an outgoing sync request.
|
|
56
|
+
*
|
|
57
|
+
* @param {Object} params
|
|
58
|
+
* @param {string} params.method - HTTP method
|
|
59
|
+
* @param {string} params.path - Canonical path
|
|
60
|
+
* @param {string} params.contentType - Content-Type header value
|
|
61
|
+
* @param {Buffer|Uint8Array} params.body - Raw request body
|
|
62
|
+
* @param {string} params.secret - Shared secret
|
|
63
|
+
* @param {string} params.keyId - Key identifier
|
|
64
|
+
* @param {Object} deps
|
|
65
|
+
* @param {import('../../ports/CryptoPort.js').default} [deps.crypto] - Crypto port
|
|
66
|
+
* @returns {Promise<Record<string, string>>} Auth headers
|
|
67
|
+
*/
|
|
68
|
+
export async function signSyncRequest({ method, path, contentType, body, secret, keyId }, { crypto } = {}) {
|
|
69
|
+
const c = crypto || defaultCrypto;
|
|
70
|
+
const timestamp = String(Date.now());
|
|
71
|
+
const nonce = globalThis.crypto.randomUUID();
|
|
72
|
+
|
|
73
|
+
const bodySha256 = await c.hash('sha256', body);
|
|
74
|
+
const canonical = buildCanonicalPayload({
|
|
75
|
+
keyId,
|
|
76
|
+
method: method.toUpperCase(),
|
|
77
|
+
path,
|
|
78
|
+
timestamp,
|
|
79
|
+
nonce,
|
|
80
|
+
contentType,
|
|
81
|
+
bodySha256,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const hmacBuf = await c.hmac(HMAC_ALGO, secret, canonical);
|
|
85
|
+
const signature = Buffer.from(hmacBuf).toString('hex');
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
'x-warp-sig-version': SIG_VERSION,
|
|
89
|
+
'x-warp-key-id': keyId,
|
|
90
|
+
'x-warp-timestamp': timestamp,
|
|
91
|
+
'x-warp-nonce': nonce,
|
|
92
|
+
'x-warp-signature': signature,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {string} reason
|
|
98
|
+
* @param {number} status
|
|
99
|
+
* @returns {{ ok: false, reason: string, status: number }}
|
|
100
|
+
*/
|
|
101
|
+
function fail(reason, status) {
|
|
102
|
+
return { ok: false, reason, status };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @returns {{ authFailCount: number, replayRejectCount: number, nonceEvictions: number, clockSkewRejects: number, malformedRejects: number, logOnlyPassthroughs: number }}
|
|
107
|
+
*/
|
|
108
|
+
function _freshMetrics() {
|
|
109
|
+
return {
|
|
110
|
+
authFailCount: 0,
|
|
111
|
+
replayRejectCount: 0,
|
|
112
|
+
nonceEvictions: 0,
|
|
113
|
+
clockSkewRejects: 0,
|
|
114
|
+
malformedRejects: 0,
|
|
115
|
+
logOnlyPassthroughs: 0,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Validates format of individual auth header values.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} timestamp
|
|
123
|
+
* @param {string} nonce
|
|
124
|
+
* @param {string} signature
|
|
125
|
+
* @returns {{ ok: false, reason: string, status: number } | { ok: true }}
|
|
126
|
+
*/
|
|
127
|
+
function _checkHeaderFormats(timestamp, nonce, signature) {
|
|
128
|
+
if (!/^\d+$/.test(timestamp) || timestamp.length > MAX_TIMESTAMP_DIGITS) {
|
|
129
|
+
return fail('MALFORMED_TIMESTAMP', 400);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!NONCE_PATTERN.test(nonce)) {
|
|
133
|
+
return fail('MALFORMED_NONCE', 400);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (signature.length !== SIG_HEX_LENGTH || !HEX_PATTERN.test(signature)) {
|
|
137
|
+
return fail('MALFORMED_SIGNATURE', 400);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { ok: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {Record<string, string>|undefined} keys
|
|
145
|
+
*/
|
|
146
|
+
function _validateKeys(keys) {
|
|
147
|
+
if (!keys || typeof keys !== 'object' || Object.keys(keys).length === 0) {
|
|
148
|
+
throw new Error('SyncAuthService requires a non-empty keys map');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export default class SyncAuthService {
|
|
153
|
+
/**
|
|
154
|
+
* @param {Object} options
|
|
155
|
+
* @param {Record<string, string>} options.keys - Key-id to secret mapping
|
|
156
|
+
* @param {'enforce'|'log-only'} [options.mode='enforce'] - Auth enforcement mode
|
|
157
|
+
* @param {number} [options.nonceCapacity] - Nonce LRU capacity
|
|
158
|
+
* @param {number} [options.maxClockSkewMs] - Max clock skew tolerance
|
|
159
|
+
* @param {import('../../ports/CryptoPort.js').default} [options.crypto] - Crypto port
|
|
160
|
+
* @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger port
|
|
161
|
+
* @param {() => number} [options.wallClockMs] - Wall clock function
|
|
162
|
+
*/
|
|
163
|
+
constructor({ keys, mode = 'enforce', nonceCapacity, maxClockSkewMs, crypto, logger, wallClockMs } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
|
|
164
|
+
_validateKeys(keys);
|
|
165
|
+
this._keys = keys;
|
|
166
|
+
this._mode = mode;
|
|
167
|
+
this._crypto = crypto || defaultCrypto;
|
|
168
|
+
this._logger = logger || nullLogger;
|
|
169
|
+
this._wallClockMs = wallClockMs || (() => Date.now());
|
|
170
|
+
this._maxClockSkewMs = typeof maxClockSkewMs === 'number' ? maxClockSkewMs : MAX_CLOCK_SKEW_MS;
|
|
171
|
+
this._nonceCache = new LRUCache(nonceCapacity || DEFAULT_NONCE_CAPACITY);
|
|
172
|
+
this._metrics = _freshMetrics();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** @returns {'enforce'|'log-only'} */
|
|
176
|
+
get mode() {
|
|
177
|
+
return this._mode;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Validates auth header presence and format.
|
|
182
|
+
*
|
|
183
|
+
* @param {Record<string, string>} headers
|
|
184
|
+
* @returns {{ ok: false, reason: string, status: number } | { ok: true, sigVersion: string, signature: string, timestamp: string, nonce: string, keyId: string }}
|
|
185
|
+
* @private
|
|
186
|
+
*/
|
|
187
|
+
_validateHeaders(headers) {
|
|
188
|
+
const sigVersion = headers['x-warp-sig-version'];
|
|
189
|
+
if (sigVersion !== SIG_VERSION) {
|
|
190
|
+
return fail('INVALID_VERSION', 400);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const keyId = headers['x-warp-key-id'];
|
|
194
|
+
const signature = headers['x-warp-signature'];
|
|
195
|
+
const timestamp = headers['x-warp-timestamp'];
|
|
196
|
+
const nonce = headers['x-warp-nonce'];
|
|
197
|
+
|
|
198
|
+
if (!keyId || !signature || !timestamp || !nonce) {
|
|
199
|
+
return fail('MISSING_AUTH', 401);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const formatCheck = _checkHeaderFormats(timestamp, nonce, signature);
|
|
203
|
+
if (!formatCheck.ok) {
|
|
204
|
+
return formatCheck;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { ok: true, sigVersion, signature, timestamp, nonce, keyId };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Checks that the timestamp is within the allowed clock skew.
|
|
212
|
+
*
|
|
213
|
+
* @param {string} timestamp - Epoch ms as string
|
|
214
|
+
* @returns {{ ok: false, reason: string, status: number } | { ok: true }}
|
|
215
|
+
* @private
|
|
216
|
+
*/
|
|
217
|
+
_validateFreshness(timestamp) {
|
|
218
|
+
const ts = Number(timestamp);
|
|
219
|
+
const now = this._wallClockMs();
|
|
220
|
+
if (Math.abs(now - ts) > this._maxClockSkewMs) {
|
|
221
|
+
this._metrics.clockSkewRejects += 1;
|
|
222
|
+
return fail('EXPIRED', 403);
|
|
223
|
+
}
|
|
224
|
+
return { ok: true };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Atomically reserves a nonce. Returns replay failure if already seen.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} nonce
|
|
231
|
+
* @returns {{ ok: false, reason: string, status: number } | { ok: true }}
|
|
232
|
+
* @private
|
|
233
|
+
*/
|
|
234
|
+
_reserveNonce(nonce) {
|
|
235
|
+
if (this._nonceCache.has(nonce)) {
|
|
236
|
+
this._metrics.replayRejectCount += 1;
|
|
237
|
+
return fail('REPLAY', 403);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const sizeBefore = this._nonceCache.size;
|
|
241
|
+
this._nonceCache.set(nonce, true);
|
|
242
|
+
if (this._nonceCache.size <= sizeBefore && sizeBefore >= this._nonceCache.maxSize) {
|
|
243
|
+
this._metrics.nonceEvictions += 1;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { ok: true };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Resolves the shared secret for a key-id.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} keyId
|
|
253
|
+
* @returns {{ ok: false, reason: string, status: number } | { ok: true, secret: string }}
|
|
254
|
+
* @private
|
|
255
|
+
*/
|
|
256
|
+
_resolveKey(keyId) {
|
|
257
|
+
const secret = this._keys[keyId];
|
|
258
|
+
if (!secret) {
|
|
259
|
+
return fail('UNKNOWN_KEY_ID', 401);
|
|
260
|
+
}
|
|
261
|
+
return { ok: true, secret };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Verifies the HMAC signature against the canonical payload.
|
|
266
|
+
*
|
|
267
|
+
* @param {Object} params
|
|
268
|
+
* @param {{ method: string, url: string, headers: Record<string, string>, body?: Buffer|Uint8Array }} params.request
|
|
269
|
+
* @param {string} params.secret
|
|
270
|
+
* @param {string} params.keyId
|
|
271
|
+
* @param {string} params.timestamp
|
|
272
|
+
* @param {string} params.nonce
|
|
273
|
+
* @returns {Promise<{ ok: false, reason: string, status: number } | { ok: true }>}
|
|
274
|
+
* @private
|
|
275
|
+
*/
|
|
276
|
+
async _verifySignature({ request, secret, keyId, timestamp, nonce }) {
|
|
277
|
+
const body = request.body || new Uint8Array(0);
|
|
278
|
+
const bodySha256 = await this._crypto.hash('sha256', body);
|
|
279
|
+
const contentType = request.headers['content-type'] || '';
|
|
280
|
+
const path = canonicalizePath(request.url || '/');
|
|
281
|
+
|
|
282
|
+
const canonical = buildCanonicalPayload({
|
|
283
|
+
keyId,
|
|
284
|
+
method: (request.method || 'POST').toUpperCase(),
|
|
285
|
+
path,
|
|
286
|
+
timestamp,
|
|
287
|
+
nonce,
|
|
288
|
+
contentType,
|
|
289
|
+
bodySha256,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const expectedBuf = await this._crypto.hmac(HMAC_ALGO, secret, canonical);
|
|
293
|
+
const receivedHex = request.headers['x-warp-signature'];
|
|
294
|
+
|
|
295
|
+
let receivedBuf;
|
|
296
|
+
try {
|
|
297
|
+
receivedBuf = Buffer.from(receivedHex, 'hex');
|
|
298
|
+
} catch {
|
|
299
|
+
return fail('INVALID_SIGNATURE', 401);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (receivedBuf.length !== expectedBuf.length) {
|
|
303
|
+
return fail('INVALID_SIGNATURE', 401);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let equal;
|
|
307
|
+
try {
|
|
308
|
+
equal = this._crypto.timingSafeEqual(
|
|
309
|
+
Buffer.from(expectedBuf),
|
|
310
|
+
receivedBuf,
|
|
311
|
+
);
|
|
312
|
+
} catch {
|
|
313
|
+
return fail('INVALID_SIGNATURE', 401);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (!equal) {
|
|
317
|
+
return fail('INVALID_SIGNATURE', 401);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { ok: true };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Verifies an incoming sync request.
|
|
325
|
+
*
|
|
326
|
+
* @param {{ method: string, url: string, headers: Record<string, string>, body?: Buffer|Uint8Array }} request
|
|
327
|
+
* @returns {Promise<{ ok: true } | { ok: false, reason: string, status: number }>}
|
|
328
|
+
*/
|
|
329
|
+
async verify(request) {
|
|
330
|
+
const headers = request.headers || {};
|
|
331
|
+
|
|
332
|
+
const headerResult = this._validateHeaders(headers);
|
|
333
|
+
if (!headerResult.ok) {
|
|
334
|
+
this._metrics.malformedRejects += 1;
|
|
335
|
+
return this._fail('header validation failed', { reason: headerResult.reason }, headerResult);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const { timestamp, nonce, keyId } = headerResult;
|
|
339
|
+
|
|
340
|
+
const freshnessResult = this._validateFreshness(timestamp);
|
|
341
|
+
if (!freshnessResult.ok) {
|
|
342
|
+
return this._fail('clock skew rejected', { keyId, timestamp }, freshnessResult);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const keyResult = this._resolveKey(keyId);
|
|
346
|
+
if (!keyResult.ok) {
|
|
347
|
+
return this._fail('unknown key-id', { keyId }, keyResult);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const sigResult = await this._verifySignature({
|
|
351
|
+
request, secret: keyResult.secret, keyId, timestamp, nonce,
|
|
352
|
+
});
|
|
353
|
+
if (!sigResult.ok) {
|
|
354
|
+
return this._fail('signature mismatch', { keyId }, sigResult);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Reserve nonce only after signature verification succeeds to avoid
|
|
358
|
+
// consuming nonces for requests with invalid signatures.
|
|
359
|
+
const nonceResult = this._reserveNonce(nonce);
|
|
360
|
+
if (!nonceResult.ok) {
|
|
361
|
+
return this._fail('replay detected', { keyId, nonce }, nonceResult);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return { ok: true };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Records an auth failure and returns the result.
|
|
369
|
+
* @param {string} message
|
|
370
|
+
* @param {Record<string, *>} context
|
|
371
|
+
* @param {{ ok: false, reason: string, status: number }} result
|
|
372
|
+
* @returns {{ ok: false, reason: string, status: number }}
|
|
373
|
+
* @private
|
|
374
|
+
*/
|
|
375
|
+
_fail(message, context, result) {
|
|
376
|
+
this._metrics.authFailCount += 1;
|
|
377
|
+
this._logger.warn(`sync auth: ${message}`, context);
|
|
378
|
+
return result;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Increments the log-only passthrough counter.
|
|
383
|
+
*/
|
|
384
|
+
recordLogOnlyPassthrough() {
|
|
385
|
+
this._metrics.logOnlyPassthroughs += 1;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Returns a snapshot of auth metrics.
|
|
390
|
+
*
|
|
391
|
+
* @returns {{ authFailCount: number, replayRejectCount: number, nonceEvictions: number, clockSkewRejects: number, malformedRejects: number, logOnlyPassthroughs: number }}
|
|
392
|
+
*/
|
|
393
|
+
getMetrics() {
|
|
394
|
+
return { ...this._metrics };
|
|
395
|
+
}
|
|
396
|
+
}
|
|
@@ -56,18 +56,16 @@ import { vvDeserialize } from '../crdt/VersionVector.js';
|
|
|
56
56
|
* **Mutation**: This function mutates the input patch object for efficiency.
|
|
57
57
|
* The original object reference is returned.
|
|
58
58
|
*
|
|
59
|
-
* @param {Object} patch - The raw decoded patch from CBOR
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* @param {Array} patch.ops - The patch operations (not modified)
|
|
63
|
-
* @returns {Object} The same patch object with context converted to Map
|
|
59
|
+
* @param {{ context?: Object | Map<any, any>, ops: any[] }} patch - The raw decoded patch from CBOR.
|
|
60
|
+
* If context is present as a plain object, it will be converted to a Map.
|
|
61
|
+
* @returns {{ context?: Object | Map<any, any>, ops: any[] }} The same patch object with context converted to Map
|
|
64
62
|
* @private
|
|
65
63
|
*/
|
|
66
64
|
function normalizePatch(patch) {
|
|
67
65
|
// Convert context from plain object to Map (VersionVector)
|
|
68
66
|
// CBOR deserialization returns plain objects, but join() expects a Map
|
|
69
67
|
if (patch.context && !(patch.context instanceof Map)) {
|
|
70
|
-
patch.context = vvDeserialize(patch.context);
|
|
68
|
+
patch.context = vvDeserialize(/** @type {{ [x: string]: number }} */ (patch.context));
|
|
71
69
|
}
|
|
72
70
|
return patch;
|
|
73
71
|
}
|
|
@@ -85,12 +83,12 @@ function normalizePatch(patch) {
|
|
|
85
83
|
* **Commit message format**: The message is encoded using WarpMessageCodec
|
|
86
84
|
* and contains metadata (schema version, writer info) plus the patch OID.
|
|
87
85
|
*
|
|
88
|
-
* @param {import('../../ports/GraphPersistencePort.js').default} persistence - Git persistence layer
|
|
86
|
+
* @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/CommitPort.js').default & import('../../ports/BlobPort.js').default} persistence - Git persistence layer
|
|
89
87
|
* (uses CommitPort.showNode() + BlobPort.readBlob() methods)
|
|
90
88
|
* @param {string} sha - The 40-character commit SHA to load the patch from
|
|
91
89
|
* @param {Object} [options]
|
|
92
90
|
* @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for deserialization
|
|
93
|
-
* @returns {Promise<Object>} The decoded and normalized patch object containing:
|
|
91
|
+
* @returns {Promise<{ context?: Object | Map<any, any>, ops: any[] }>} The decoded and normalized patch object containing:
|
|
94
92
|
* - `ops`: Array of patch operations
|
|
95
93
|
* - `context`: VersionVector (Map) of causal dependencies
|
|
96
94
|
* - `writerId`: The writer who created this patch
|
|
@@ -101,7 +99,7 @@ function normalizePatch(patch) {
|
|
|
101
99
|
* @throws {Error} If the patch blob cannot be CBOR-decoded (corrupted data)
|
|
102
100
|
* @private
|
|
103
101
|
*/
|
|
104
|
-
async function loadPatchFromCommit(persistence, sha, { codec: codecOpt } = {}) {
|
|
102
|
+
async function loadPatchFromCommit(persistence, sha, { codec: codecOpt } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
|
|
105
103
|
const codec = codecOpt || defaultCodec;
|
|
106
104
|
// Read commit message to extract patch OID
|
|
107
105
|
const message = await persistence.showNode(sha);
|
|
@@ -109,7 +107,7 @@ async function loadPatchFromCommit(persistence, sha, { codec: codecOpt } = {}) {
|
|
|
109
107
|
|
|
110
108
|
// Read and decode the patch blob
|
|
111
109
|
const patchBuffer = await persistence.readBlob(decoded.patchOid);
|
|
112
|
-
const patch = codec.decode(patchBuffer);
|
|
110
|
+
const patch = /** @type {{ context?: Object | Map<any, any>, ops: any[] }} */ (codec.decode(patchBuffer));
|
|
113
111
|
|
|
114
112
|
// Normalize the patch (convert context from object to Map)
|
|
115
113
|
return normalizePatch(patch);
|
|
@@ -129,7 +127,7 @@ async function loadPatchFromCommit(persistence, sha, { codec: codecOpt } = {}) {
|
|
|
129
127
|
* **Performance**: O(N) where N is the number of commits between fromSha and toSha.
|
|
130
128
|
* Each commit requires two reads: commit info (for parent) and patch blob.
|
|
131
129
|
*
|
|
132
|
-
* @param {import('../../ports/GraphPersistencePort.js').default} persistence - Git persistence layer
|
|
130
|
+
* @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/CommitPort.js').default & import('../../ports/BlobPort.js').default} persistence - Git persistence layer
|
|
133
131
|
* (uses CommitPort.getNodeInfo()/showNode() + BlobPort.readBlob() methods)
|
|
134
132
|
* @param {string} graphName - Graph name (used in error messages, not for lookups)
|
|
135
133
|
* @param {string} writerId - Writer ID (used in error messages, not for lookups)
|
|
@@ -154,7 +152,7 @@ async function loadPatchFromCommit(persistence, sha, { codec: codecOpt } = {}) {
|
|
|
154
152
|
* // Load ALL patches for a new writer
|
|
155
153
|
* const patches = await loadPatchRange(persistence, 'events', 'new-writer', null, tipSha);
|
|
156
154
|
*/
|
|
157
|
-
export async function loadPatchRange(persistence, graphName, writerId, fromSha, toSha, { codec } = {}) {
|
|
155
|
+
export async function loadPatchRange(persistence, graphName, writerId, fromSha, toSha, { codec } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
|
|
158
156
|
const patches = [];
|
|
159
157
|
let cur = toSha;
|
|
160
158
|
|
|
@@ -172,9 +170,9 @@ export async function loadPatchRange(persistence, graphName, writerId, fromSha,
|
|
|
172
170
|
|
|
173
171
|
// If fromSha was specified but we didn't reach it, we have divergence
|
|
174
172
|
if (fromSha && cur === null) {
|
|
175
|
-
const err = new Error(
|
|
173
|
+
const err = /** @type {Error & { code: string }} */ (new Error(
|
|
176
174
|
`Divergence detected: ${toSha} does not descend from ${fromSha} for writer ${writerId}`
|
|
177
|
-
);
|
|
175
|
+
));
|
|
178
176
|
err.code = 'E_SYNC_DIVERGENCE';
|
|
179
177
|
throw err;
|
|
180
178
|
}
|
|
@@ -214,11 +212,7 @@ export async function loadPatchRange(persistence, graphName, writerId, fromSha,
|
|
|
214
212
|
* Maps writerId to the SHA of their latest patch commit.
|
|
215
213
|
* @param {Map<string, string>} remoteFrontier - Remote writer heads.
|
|
216
214
|
* Maps writerId to the SHA of their latest patch commit.
|
|
217
|
-
* @returns {
|
|
218
|
-
* - `needFromRemote`: Map<writerId, {from: string|null, to: string}> - Patches local needs
|
|
219
|
-
* - `needFromLocal`: Map<writerId, {from: string|null, to: string}> - Patches remote needs
|
|
220
|
-
* - `newWritersForLocal`: string[] - Writers that local has never seen
|
|
221
|
-
* - `newWritersForRemote`: string[] - Writers that remote has never seen
|
|
215
|
+
* @returns {{ needFromRemote: Map<string, {from: string|null, to: string}>, needFromLocal: Map<string, {from: string|null, to: string}>, newWritersForLocal: string[], newWritersForRemote: string[] }} Sync delta
|
|
222
216
|
*
|
|
223
217
|
* @example
|
|
224
218
|
* const local = new Map([['w1', 'sha-a'], ['w2', 'sha-b']]);
|
|
@@ -333,13 +327,14 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
|
|
|
333
327
|
*/
|
|
334
328
|
export function createSyncRequest(frontier) {
|
|
335
329
|
// Convert Map to plain object for serialization
|
|
330
|
+
/** @type {{ [x: string]: string }} */
|
|
336
331
|
const frontierObj = {};
|
|
337
332
|
for (const [writerId, sha] of frontier) {
|
|
338
333
|
frontierObj[writerId] = sha;
|
|
339
334
|
}
|
|
340
335
|
|
|
341
336
|
return {
|
|
342
|
-
type: 'sync-request',
|
|
337
|
+
type: /** @type {'sync-request'} */ ('sync-request'),
|
|
343
338
|
frontier: frontierObj,
|
|
344
339
|
};
|
|
345
340
|
}
|
|
@@ -363,7 +358,7 @@ export function createSyncRequest(frontier) {
|
|
|
363
358
|
*
|
|
364
359
|
* @param {SyncRequest} request - Incoming sync request containing the requester's frontier
|
|
365
360
|
* @param {Map<string, string>} localFrontier - Local frontier (what this node has)
|
|
366
|
-
* @param {import('../../ports/GraphPersistencePort.js').default} persistence - Git persistence
|
|
361
|
+
* @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/CommitPort.js').default & import('../../ports/BlobPort.js').default} persistence - Git persistence
|
|
367
362
|
* layer for loading patches (uses CommitPort + BlobPort methods)
|
|
368
363
|
* @param {string} graphName - Graph name for error messages and logging
|
|
369
364
|
* @returns {Promise<SyncResponse>} Response containing local frontier and patches.
|
|
@@ -379,7 +374,7 @@ export function createSyncRequest(frontier) {
|
|
|
379
374
|
* res.json(response);
|
|
380
375
|
* });
|
|
381
376
|
*/
|
|
382
|
-
export async function processSyncRequest(request, localFrontier, persistence, graphName, { codec } = {}) {
|
|
377
|
+
export async function processSyncRequest(request, localFrontier, persistence, graphName, { codec } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
|
|
383
378
|
// Convert incoming frontier from object to Map
|
|
384
379
|
const remoteFrontier = new Map(Object.entries(request.frontier));
|
|
385
380
|
|
|
@@ -406,7 +401,7 @@ export async function processSyncRequest(request, localFrontier, persistence, gr
|
|
|
406
401
|
} catch (err) {
|
|
407
402
|
// If we detect divergence, skip this writer
|
|
408
403
|
// The requester may need to handle this separately
|
|
409
|
-
if (err.code === 'E_SYNC_DIVERGENCE' || err.message
|
|
404
|
+
if (/** @type {any} */ (err).code === 'E_SYNC_DIVERGENCE' || /** @type {any} */ (err).message?.includes('Divergence detected')) { // TODO(ts-cleanup): type error
|
|
410
405
|
continue;
|
|
411
406
|
}
|
|
412
407
|
throw err;
|
|
@@ -414,13 +409,14 @@ export async function processSyncRequest(request, localFrontier, persistence, gr
|
|
|
414
409
|
}
|
|
415
410
|
|
|
416
411
|
// Convert local frontier to plain object
|
|
412
|
+
/** @type {{ [x: string]: string }} */
|
|
417
413
|
const frontierObj = {};
|
|
418
414
|
for (const [writerId, sha] of localFrontier) {
|
|
419
415
|
frontierObj[writerId] = sha;
|
|
420
416
|
}
|
|
421
417
|
|
|
422
418
|
return {
|
|
423
|
-
type: 'sync-response',
|
|
419
|
+
type: /** @type {'sync-response'} */ ('sync-response'),
|
|
424
420
|
frontier: frontierObj,
|
|
425
421
|
patches,
|
|
426
422
|
};
|
|
@@ -495,7 +491,7 @@ export function applySyncResponse(response, state, frontier) {
|
|
|
495
491
|
// will prevent silent data loss until the reader is upgraded.
|
|
496
492
|
assertOpsCompatible(normalizedPatch.ops, SCHEMA_V3);
|
|
497
493
|
// Apply patch to state
|
|
498
|
-
join(newState, normalizedPatch, sha);
|
|
494
|
+
join(newState, /** @type {*} */ (normalizedPatch), sha); // TODO(ts-cleanup): type patch array
|
|
499
495
|
applied++;
|
|
500
496
|
}
|
|
501
497
|
|
|
@@ -580,13 +576,14 @@ export function syncNeeded(localFrontier, remoteFrontier) {
|
|
|
580
576
|
* }
|
|
581
577
|
*/
|
|
582
578
|
export function createEmptySyncResponse(frontier) {
|
|
579
|
+
/** @type {{ [x: string]: string }} */
|
|
583
580
|
const frontierObj = {};
|
|
584
581
|
for (const [writerId, sha] of frontier) {
|
|
585
582
|
frontierObj[writerId] = sha;
|
|
586
583
|
}
|
|
587
584
|
|
|
588
585
|
return {
|
|
589
|
-
type: 'sync-response',
|
|
586
|
+
type: /** @type {'sync-response'} */ ('sync-response'),
|
|
590
587
|
frontier: frontierObj,
|
|
591
588
|
patches: [],
|
|
592
589
|
};
|
|
@@ -60,10 +60,11 @@ function unwrapValue(value) {
|
|
|
60
60
|
*
|
|
61
61
|
* @param {import('./JoinReducer.js').WarpStateV5} state - Current state
|
|
62
62
|
* @param {string} nodeId - Node ID to extract
|
|
63
|
-
* @returns {{ id: string, exists: boolean, props:
|
|
63
|
+
* @returns {{ id: string, exists: boolean, props: Record<string, *> }}
|
|
64
64
|
*/
|
|
65
65
|
function extractNodeSnapshot(state, nodeId) {
|
|
66
66
|
const exists = orsetContains(state.nodeAlive, nodeId);
|
|
67
|
+
/** @type {Record<string, *>} */
|
|
67
68
|
const props = {};
|
|
68
69
|
|
|
69
70
|
if (exists) {
|
|
@@ -108,7 +109,7 @@ export class TemporalQuery {
|
|
|
108
109
|
* @param {string} nodeId - The node ID to evaluate
|
|
109
110
|
* @param {Function} predicate - Predicate receiving node snapshot
|
|
110
111
|
* `{ id, exists, props }`. Should return boolean.
|
|
111
|
-
* @param {
|
|
112
|
+
* @param {Object} [options={}] - Options
|
|
112
113
|
* @param {number} [options.since=0] - Minimum Lamport tick (inclusive).
|
|
113
114
|
* Only patches with lamport >= since are considered.
|
|
114
115
|
* @returns {Promise<boolean>} True if predicate held at every tick
|
|
@@ -161,7 +162,7 @@ export class TemporalQuery {
|
|
|
161
162
|
* @param {string} nodeId - The node ID to evaluate
|
|
162
163
|
* @param {Function} predicate - Predicate receiving node snapshot
|
|
163
164
|
* `{ id, exists, props }`. Should return boolean.
|
|
164
|
-
* @param {
|
|
165
|
+
* @param {Object} [options={}] - Options
|
|
165
166
|
* @param {number} [options.since=0] - Minimum Lamport tick (inclusive).
|
|
166
167
|
* Only patches with lamport >= since are considered.
|
|
167
168
|
* @returns {Promise<boolean>} True if predicate held at any tick
|
|
@@ -94,8 +94,8 @@ function zeroCost() {
|
|
|
94
94
|
/**
|
|
95
95
|
* Counts how many items in `source` are absent from `targetSet`.
|
|
96
96
|
*
|
|
97
|
-
* @param {Array
|
|
98
|
-
* @param {Set} targetSet - Target set to test against
|
|
97
|
+
* @param {Array<string>|Set<string>} source - Source collection
|
|
98
|
+
* @param {Set<string>} targetSet - Target set to test against
|
|
99
99
|
* @returns {number}
|
|
100
100
|
*/
|
|
101
101
|
function countMissing(source, targetSet) {
|
|
@@ -141,7 +141,7 @@ function computeEdgeLoss(state, nodesASet, nodesBSet) {
|
|
|
141
141
|
* Counts lost properties for a single node between two observer configs.
|
|
142
142
|
*
|
|
143
143
|
* @param {Map<string, boolean>} nodeProps - Property keys for the node
|
|
144
|
-
* @param {{ configA:
|
|
144
|
+
* @param {{ configA: {expose?: string[], redact?: string[]}, configB: {expose?: string[], redact?: string[]}, nodeInB: boolean }} opts
|
|
145
145
|
* @returns {{ propsInA: number, lostProps: number }}
|
|
146
146
|
*/
|
|
147
147
|
function countNodePropLoss(nodeProps, { configA, configB, nodeInB }) {
|
|
@@ -157,7 +157,7 @@ function countNodePropLoss(nodeProps, { configA, configB, nodeInB }) {
|
|
|
157
157
|
* Computes property loss across all A-visible nodes.
|
|
158
158
|
*
|
|
159
159
|
* @param {*} state - WarpStateV5
|
|
160
|
-
* @param {{ nodesA: string[], nodesBSet: Set<string>, configA:
|
|
160
|
+
* @param {{ nodesA: string[], nodesBSet: Set<string>, configA: {expose?: string[], redact?: string[]}, configB: {expose?: string[], redact?: string[]} }} opts
|
|
161
161
|
* @returns {number} propLoss fraction
|
|
162
162
|
*/
|
|
163
163
|
function computePropLoss(state, { nodesA, nodesBSet, configA, configB }) {
|