@git-stunts/git-warp 10.4.2 → 10.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SECURITY.md +89 -1
- package/bin/warp-graph.js +205 -69
- package/index.d.ts +24 -0
- package/package.json +1 -2
- package/src/domain/WarpGraph.js +72 -15
- package/src/domain/services/HttpSyncServer.js +74 -6
- package/src/domain/services/SyncAuthService.js +396 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +9 -56
- package/src/infrastructure/adapters/InMemoryGraphAdapter.js +488 -0
- package/src/infrastructure/adapters/adapterValidation.js +90 -0
- package/src/visualization/renderers/ascii/seek.js +172 -22
package/src/domain/WarpGraph.js
CHANGED
|
@@ -49,6 +49,7 @@ import OperationAbortedError from './errors/OperationAbortedError.js';
|
|
|
49
49
|
import { compareEventIds } from './utils/EventId.js';
|
|
50
50
|
import { TemporalQuery } from './services/TemporalQuery.js';
|
|
51
51
|
import HttpSyncServer from './services/HttpSyncServer.js';
|
|
52
|
+
import { signSyncRequest, canonicalizePath } from './services/SyncAuthService.js';
|
|
52
53
|
import { buildSeekCacheKey } from './utils/seekCacheKey.js';
|
|
53
54
|
import defaultClock from './utils/defaultClock.js';
|
|
54
55
|
|
|
@@ -77,6 +78,35 @@ function normalizeSyncPath(path) {
|
|
|
77
78
|
return path.startsWith('/') ? path : `/${path}`;
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Builds auth headers for an outgoing sync request if auth is configured.
|
|
83
|
+
*
|
|
84
|
+
* @param {Object} params
|
|
85
|
+
* @param {{ secret: string, keyId?: string }|undefined} params.auth
|
|
86
|
+
* @param {string} params.bodyStr - Serialized request body
|
|
87
|
+
* @param {URL} params.targetUrl
|
|
88
|
+
* @param {import('../ports/CryptoPort.js').default} params.crypto
|
|
89
|
+
* @returns {Promise<Record<string, string>>}
|
|
90
|
+
* @private
|
|
91
|
+
*/
|
|
92
|
+
async function buildSyncAuthHeaders({ auth, bodyStr, targetUrl, crypto }) {
|
|
93
|
+
if (!auth || !auth.secret) {
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
const bodyBuf = new TextEncoder().encode(bodyStr);
|
|
97
|
+
return await signSyncRequest(
|
|
98
|
+
{
|
|
99
|
+
method: 'POST',
|
|
100
|
+
path: canonicalizePath(targetUrl.pathname + (targetUrl.search || '')),
|
|
101
|
+
contentType: 'application/json',
|
|
102
|
+
body: bodyBuf,
|
|
103
|
+
secret: auth.secret,
|
|
104
|
+
keyId: auth.keyId || 'default',
|
|
105
|
+
},
|
|
106
|
+
{ crypto },
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
80
110
|
const DEFAULT_ADJACENCY_CACHE_SIZE = 3;
|
|
81
111
|
|
|
82
112
|
/**
|
|
@@ -2141,18 +2171,15 @@ export default class WarpGraph {
|
|
|
2141
2171
|
* @param {string|WarpGraph} remote - URL or peer graph instance
|
|
2142
2172
|
* @param {Object} [options]
|
|
2143
2173
|
* @param {string} [options.path='/sync'] - Sync path (HTTP mode)
|
|
2144
|
-
* @param {number} [options.retries=3] - Retry count
|
|
2174
|
+
* @param {number} [options.retries=3] - Retry count
|
|
2145
2175
|
* @param {number} [options.baseDelayMs=250] - Base backoff delay
|
|
2146
2176
|
* @param {number} [options.maxDelayMs=2000] - Max backoff delay
|
|
2147
|
-
* @param {number} [options.timeoutMs=10000] - Request timeout
|
|
2148
|
-
* @param {AbortSignal} [options.signal] -
|
|
2177
|
+
* @param {number} [options.timeoutMs=10000] - Request timeout
|
|
2178
|
+
* @param {AbortSignal} [options.signal] - Abort signal
|
|
2149
2179
|
* @param {(event: {type: string, attempt: number, durationMs?: number, status?: number, error?: Error}) => void} [options.onStatus]
|
|
2150
|
-
* @param {boolean} [options.materialize=false] -
|
|
2180
|
+
* @param {boolean} [options.materialize=false] - Auto-materialize after sync
|
|
2181
|
+
* @param {{ secret: string, keyId?: string }} [options.auth] - Client auth credentials
|
|
2151
2182
|
* @returns {Promise<{applied: number, attempts: number, state?: import('./services/JoinReducer.js').WarpStateV5}>}
|
|
2152
|
-
* @throws {SyncError} If remote URL is invalid (code: `E_SYNC_REMOTE_URL`)
|
|
2153
|
-
* @throws {SyncError} If remote returns error or invalid response (code: `E_SYNC_REMOTE`, `E_SYNC_PROTOCOL`)
|
|
2154
|
-
* @throws {SyncError} If request times out (code: `E_SYNC_TIMEOUT`)
|
|
2155
|
-
* @throws {OperationAbortedError} If abort signal fires
|
|
2156
2183
|
*/
|
|
2157
2184
|
async syncWith(remote, options = {}) {
|
|
2158
2185
|
const t0 = this._clock.now();
|
|
@@ -2165,6 +2192,7 @@ export default class WarpGraph {
|
|
|
2165
2192
|
signal,
|
|
2166
2193
|
onStatus,
|
|
2167
2194
|
materialize: materializeAfterSync = false,
|
|
2195
|
+
auth,
|
|
2168
2196
|
} = options;
|
|
2169
2197
|
|
|
2170
2198
|
const hasPathOverride = Object.prototype.hasOwnProperty.call(options, 'path');
|
|
@@ -2196,14 +2224,12 @@ export default class WarpGraph {
|
|
|
2196
2224
|
}
|
|
2197
2225
|
targetUrl.hash = '';
|
|
2198
2226
|
}
|
|
2199
|
-
|
|
2200
2227
|
let attempt = 0;
|
|
2201
2228
|
const emit = (/** @type {string} */ type, /** @type {Record<string, any>} */ payload = {}) => {
|
|
2202
2229
|
if (typeof onStatus === 'function') {
|
|
2203
2230
|
onStatus(/** @type {any} */ ({ type, attempt, ...payload })); // TODO(ts-cleanup): type sync protocol
|
|
2204
2231
|
}
|
|
2205
2232
|
};
|
|
2206
|
-
|
|
2207
2233
|
const shouldRetry = (/** @type {any} */ err) => { // TODO(ts-cleanup): type error
|
|
2208
2234
|
if (isDirectPeer) { return false; }
|
|
2209
2235
|
if (err instanceof SyncError) {
|
|
@@ -2211,16 +2237,13 @@ export default class WarpGraph {
|
|
|
2211
2237
|
}
|
|
2212
2238
|
return err instanceof TimeoutError;
|
|
2213
2239
|
};
|
|
2214
|
-
|
|
2215
2240
|
const executeAttempt = async () => {
|
|
2216
2241
|
checkAborted(signal, 'syncWith');
|
|
2217
2242
|
attempt += 1;
|
|
2218
2243
|
const attemptStart = Date.now();
|
|
2219
2244
|
emit('connecting');
|
|
2220
|
-
|
|
2221
2245
|
const request = await this.createSyncRequest();
|
|
2222
2246
|
emit('requestBuilt');
|
|
2223
|
-
|
|
2224
2247
|
let response;
|
|
2225
2248
|
if (isDirectPeer) {
|
|
2226
2249
|
emit('requestSent');
|
|
@@ -2228,6 +2251,10 @@ export default class WarpGraph {
|
|
|
2228
2251
|
emit('responseReceived');
|
|
2229
2252
|
} else {
|
|
2230
2253
|
emit('requestSent');
|
|
2254
|
+
const bodyStr = JSON.stringify(request);
|
|
2255
|
+
const authHeaders = await buildSyncAuthHeaders({
|
|
2256
|
+
auth, bodyStr, targetUrl: /** @type {URL} */ (targetUrl), crypto: this._crypto,
|
|
2257
|
+
});
|
|
2231
2258
|
let res;
|
|
2232
2259
|
try {
|
|
2233
2260
|
res = await timeout(timeoutMs, (timeoutSignal) => {
|
|
@@ -2239,8 +2266,9 @@ export default class WarpGraph {
|
|
|
2239
2266
|
headers: {
|
|
2240
2267
|
'content-type': 'application/json',
|
|
2241
2268
|
'accept': 'application/json',
|
|
2269
|
+
...authHeaders,
|
|
2242
2270
|
},
|
|
2243
|
-
body:
|
|
2271
|
+
body: bodyStr,
|
|
2244
2272
|
signal: combinedSignal,
|
|
2245
2273
|
});
|
|
2246
2274
|
});
|
|
@@ -2363,11 +2391,12 @@ export default class WarpGraph {
|
|
|
2363
2391
|
* @param {string} [options.path='/sync'] - Path to handle sync requests
|
|
2364
2392
|
* @param {number} [options.maxRequestBytes=4194304] - Max request size in bytes
|
|
2365
2393
|
* @param {import('../ports/HttpServerPort.js').default} options.httpPort - HTTP server adapter (required)
|
|
2394
|
+
* @param {{ keys: Record<string, string>, mode?: 'enforce'|'log-only' }} [options.auth] - Auth configuration
|
|
2366
2395
|
* @returns {Promise<{close: () => Promise<void>, url: string}>} Server handle
|
|
2367
2396
|
* @throws {Error} If port is not a number
|
|
2368
2397
|
* @throws {Error} If httpPort adapter is not provided
|
|
2369
2398
|
*/
|
|
2370
|
-
async serve({ port, host = '127.0.0.1', path = '/sync', maxRequestBytes = DEFAULT_SYNC_SERVER_MAX_BYTES, httpPort } = /** @type {any} */ ({})) { // TODO(ts-cleanup): needs options type
|
|
2399
|
+
async serve({ port, host = '127.0.0.1', path = '/sync', maxRequestBytes = DEFAULT_SYNC_SERVER_MAX_BYTES, httpPort, auth } = /** @type {any} */ ({})) { // TODO(ts-cleanup): needs options type
|
|
2371
2400
|
if (typeof port !== 'number') {
|
|
2372
2401
|
throw new Error('serve() requires a numeric port');
|
|
2373
2402
|
}
|
|
@@ -2375,12 +2404,17 @@ export default class WarpGraph {
|
|
|
2375
2404
|
throw new Error('serve() requires an httpPort adapter');
|
|
2376
2405
|
}
|
|
2377
2406
|
|
|
2407
|
+
const authConfig = auth
|
|
2408
|
+
? { ...auth, crypto: this._crypto, logger: this._logger || undefined }
|
|
2409
|
+
: undefined;
|
|
2410
|
+
|
|
2378
2411
|
const httpServer = new HttpSyncServer({
|
|
2379
2412
|
httpPort,
|
|
2380
2413
|
graph: this,
|
|
2381
2414
|
path,
|
|
2382
2415
|
host,
|
|
2383
2416
|
maxRequestBytes,
|
|
2417
|
+
auth: authConfig,
|
|
2384
2418
|
});
|
|
2385
2419
|
|
|
2386
2420
|
return await httpServer.listen(port);
|
|
@@ -2804,6 +2838,29 @@ export default class WarpGraph {
|
|
|
2804
2838
|
return neighbors;
|
|
2805
2839
|
}
|
|
2806
2840
|
|
|
2841
|
+
/**
|
|
2842
|
+
* Returns a defensive copy of the current materialized state.
|
|
2843
|
+
*
|
|
2844
|
+
* The returned object is a shallow clone: top-level ORSet, LWW, and
|
|
2845
|
+
* VersionVector instances are copied so that mutations by the caller
|
|
2846
|
+
* cannot corrupt the internal cache.
|
|
2847
|
+
*
|
|
2848
|
+
* **Requires a cached state.** Call materialize() first if not already cached.
|
|
2849
|
+
*
|
|
2850
|
+
* @returns {Promise<import('./services/JoinReducer.js').WarpStateV5 | null>}
|
|
2851
|
+
* Cloned state, or null if no state has been materialized yet.
|
|
2852
|
+
*/
|
|
2853
|
+
async getStateSnapshot() {
|
|
2854
|
+
if (!this._cachedState && !this._autoMaterialize) {
|
|
2855
|
+
return null;
|
|
2856
|
+
}
|
|
2857
|
+
await this._ensureFreshState();
|
|
2858
|
+
if (!this._cachedState) {
|
|
2859
|
+
return null;
|
|
2860
|
+
}
|
|
2861
|
+
return cloneStateV5(/** @type {import('./services/JoinReducer.js').WarpStateV5} */ (this._cachedState));
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2807
2864
|
/**
|
|
2808
2865
|
* Gets all visible nodes in the materialized state.
|
|
2809
2866
|
*
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* @module domain/services/HttpSyncServer
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import SyncAuthService from './SyncAuthService.js';
|
|
12
|
+
|
|
11
13
|
const DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024;
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -140,18 +142,28 @@ function validateRoute(request, expectedPath, defaultHost) {
|
|
|
140
142
|
}
|
|
141
143
|
|
|
142
144
|
/**
|
|
143
|
-
*
|
|
145
|
+
* Checks if the request body exceeds the maximum allowed size.
|
|
144
146
|
*
|
|
145
147
|
* @param {Buffer|undefined} body
|
|
146
148
|
* @param {number} maxBytes
|
|
147
|
-
* @returns {{
|
|
149
|
+
* @returns {{ status: number, headers: Object, body: string }|null} Error response or null if within limits
|
|
148
150
|
* @private
|
|
149
151
|
*/
|
|
150
|
-
function
|
|
152
|
+
function checkBodySize(body, maxBytes) {
|
|
151
153
|
if (body && body.length > maxBytes) {
|
|
152
|
-
return
|
|
154
|
+
return errorResponse(413, 'Request too large');
|
|
153
155
|
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
154
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Parses and validates the request body as a sync request.
|
|
161
|
+
*
|
|
162
|
+
* @param {Buffer|undefined} body
|
|
163
|
+
* @returns {{ error: { status: number, headers: Object, body: string }, parsed: null } | { error: null, parsed: Object }}
|
|
164
|
+
* @private
|
|
165
|
+
*/
|
|
166
|
+
function parseBody(body) {
|
|
155
167
|
const bodyStr = body ? body.toString('utf-8') : '';
|
|
156
168
|
|
|
157
169
|
let parsed;
|
|
@@ -168,6 +180,25 @@ function parseBody(body, maxBytes) {
|
|
|
168
180
|
return { error: null, parsed };
|
|
169
181
|
}
|
|
170
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Initializes auth service from config if present.
|
|
185
|
+
*
|
|
186
|
+
* @param {{ keys: Record<string, string>, mode?: 'enforce'|'log-only', crypto?: *, logger?: *, wallClockMs?: () => number }|undefined} auth
|
|
187
|
+
* @returns {{ auth: SyncAuthService|null, authMode: string|null }}
|
|
188
|
+
* @private
|
|
189
|
+
*/
|
|
190
|
+
function initAuth(auth) {
|
|
191
|
+
if (auth && auth.keys) {
|
|
192
|
+
const VALID_MODES = new Set(['enforce', 'log-only']);
|
|
193
|
+
const mode = auth.mode || 'enforce';
|
|
194
|
+
if (!VALID_MODES.has(mode)) {
|
|
195
|
+
throw new Error(`Invalid auth.mode: '${mode}'. Must be 'enforce' or 'log-only'.`);
|
|
196
|
+
}
|
|
197
|
+
return { auth: new SyncAuthService(auth), authMode: mode };
|
|
198
|
+
}
|
|
199
|
+
return { auth: null, authMode: null };
|
|
200
|
+
}
|
|
201
|
+
|
|
171
202
|
export default class HttpSyncServer {
|
|
172
203
|
/**
|
|
173
204
|
* @param {Object} options
|
|
@@ -176,14 +207,18 @@ export default class HttpSyncServer {
|
|
|
176
207
|
* @param {string} [options.path='/sync'] - URL path to handle sync requests on
|
|
177
208
|
* @param {string} [options.host='127.0.0.1'] - Host to bind
|
|
178
209
|
* @param {number} [options.maxRequestBytes=4194304] - Maximum request body size in bytes
|
|
210
|
+
* @param {{ keys: Record<string, string>, mode?: 'enforce'|'log-only', crypto?: import('../../ports/CryptoPort.js').default, logger?: import('../../ports/LoggerPort.js').default, wallClockMs?: () => number }} [options.auth] - Auth configuration
|
|
179
211
|
*/
|
|
180
|
-
constructor({ httpPort, graph, path = '/sync', host = '127.0.0.1', maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
|
|
212
|
+
constructor({ httpPort, graph, path = '/sync', host = '127.0.0.1', maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES, auth } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
|
|
181
213
|
this._httpPort = httpPort;
|
|
182
214
|
this._graph = graph;
|
|
183
215
|
this._path = path && path.startsWith('/') ? path : `/${path || 'sync'}`;
|
|
184
216
|
this._host = host;
|
|
185
217
|
this._maxRequestBytes = maxRequestBytes;
|
|
186
218
|
this._server = null;
|
|
219
|
+
const authInit = initAuth(auth);
|
|
220
|
+
this._auth = authInit.auth;
|
|
221
|
+
this._authMode = authInit.authMode;
|
|
187
222
|
}
|
|
188
223
|
|
|
189
224
|
/**
|
|
@@ -193,6 +228,29 @@ export default class HttpSyncServer {
|
|
|
193
228
|
* @returns {Promise<{ status: number, headers: Object, body: string }>}
|
|
194
229
|
* @private
|
|
195
230
|
*/
|
|
231
|
+
/**
|
|
232
|
+
* Runs auth verification if configured. Returns an error response to
|
|
233
|
+
* send, or null if the request should proceed.
|
|
234
|
+
*
|
|
235
|
+
* @param {*} request
|
|
236
|
+
* @returns {Promise<{ status: number, headers: Object, body: string }|null>}
|
|
237
|
+
* @private
|
|
238
|
+
*/
|
|
239
|
+
async _checkAuth(request) {
|
|
240
|
+
if (!this._auth) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
const result = await this._auth.verify(request);
|
|
244
|
+
if (!result.ok) {
|
|
245
|
+
if (this._authMode === 'enforce') {
|
|
246
|
+
return errorResponse(result.status, result.reason);
|
|
247
|
+
}
|
|
248
|
+
this._auth.recordLogOnlyPassthrough();
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** @param {{ method: string, url: string, headers: { [x: string]: string }, body: Buffer|undefined }} request */
|
|
196
254
|
async _handleRequest(request) {
|
|
197
255
|
const contentTypeError = checkContentType(request.headers);
|
|
198
256
|
if (contentTypeError) {
|
|
@@ -204,7 +262,17 @@ export default class HttpSyncServer {
|
|
|
204
262
|
return routeError;
|
|
205
263
|
}
|
|
206
264
|
|
|
207
|
-
const
|
|
265
|
+
const sizeError = checkBodySize(request.body, this._maxRequestBytes);
|
|
266
|
+
if (sizeError) {
|
|
267
|
+
return sizeError;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const authError = await this._checkAuth(request);
|
|
271
|
+
if (authError) {
|
|
272
|
+
return authError;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const { error, parsed } = parseBody(request.body);
|
|
208
276
|
if (error) {
|
|
209
277
|
return error;
|
|
210
278
|
}
|