@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.
@@ -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 for retryable failures
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 (HTTP mode)
2148
- * @param {AbortSignal} [options.signal] - Optional abort signal to cancel sync
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] - If true, auto-materialize after sync and include state in result
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: JSON.stringify(request),
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
- * Parses and validates the request body as a sync request.
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 {{ error: { status: number, headers: Object, body: string }, parsed: null } | { error: null, parsed: Object }}
149
+ * @returns {{ status: number, headers: Object, body: string }|null} Error response or null if within limits
148
150
  * @private
149
151
  */
150
- function parseBody(body, maxBytes) {
152
+ function checkBodySize(body, maxBytes) {
151
153
  if (body && body.length > maxBytes) {
152
- return { error: errorResponse(413, 'Request too large'), parsed: null };
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 { error, parsed } = parseBody(request.body, this._maxRequestBytes);
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
  }