@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.
Files changed (108) hide show
  1. package/README.md +6 -3
  2. package/SECURITY.md +89 -1
  3. package/bin/warp-graph.js +574 -208
  4. package/index.d.ts +55 -0
  5. package/index.js +4 -0
  6. package/package.json +8 -4
  7. package/src/domain/WarpGraph.js +334 -161
  8. package/src/domain/crdt/LWW.js +1 -1
  9. package/src/domain/crdt/ORSet.js +10 -6
  10. package/src/domain/crdt/VersionVector.js +5 -1
  11. package/src/domain/errors/EmptyMessageError.js +2 -4
  12. package/src/domain/errors/ForkError.js +4 -0
  13. package/src/domain/errors/IndexError.js +4 -0
  14. package/src/domain/errors/OperationAbortedError.js +4 -0
  15. package/src/domain/errors/QueryError.js +4 -0
  16. package/src/domain/errors/SchemaUnsupportedError.js +4 -0
  17. package/src/domain/errors/ShardCorruptionError.js +2 -6
  18. package/src/domain/errors/ShardLoadError.js +2 -6
  19. package/src/domain/errors/ShardValidationError.js +2 -7
  20. package/src/domain/errors/StorageError.js +2 -6
  21. package/src/domain/errors/SyncError.js +4 -0
  22. package/src/domain/errors/TraversalError.js +4 -0
  23. package/src/domain/errors/WarpError.js +2 -4
  24. package/src/domain/errors/WormholeError.js +4 -0
  25. package/src/domain/services/AnchorMessageCodec.js +1 -4
  26. package/src/domain/services/BitmapIndexBuilder.js +10 -6
  27. package/src/domain/services/BitmapIndexReader.js +27 -21
  28. package/src/domain/services/BoundaryTransitionRecord.js +22 -15
  29. package/src/domain/services/CheckpointMessageCodec.js +1 -7
  30. package/src/domain/services/CheckpointSerializerV5.js +20 -19
  31. package/src/domain/services/CheckpointService.js +18 -18
  32. package/src/domain/services/CommitDagTraversalService.js +13 -1
  33. package/src/domain/services/DagPathFinding.js +40 -18
  34. package/src/domain/services/DagTopology.js +7 -6
  35. package/src/domain/services/DagTraversal.js +5 -3
  36. package/src/domain/services/Frontier.js +7 -6
  37. package/src/domain/services/HealthCheckService.js +15 -14
  38. package/src/domain/services/HookInstaller.js +64 -13
  39. package/src/domain/services/HttpSyncServer.js +88 -19
  40. package/src/domain/services/IndexRebuildService.js +12 -12
  41. package/src/domain/services/IndexStalenessChecker.js +13 -6
  42. package/src/domain/services/JoinReducer.js +28 -27
  43. package/src/domain/services/LogicalTraversal.js +7 -6
  44. package/src/domain/services/MessageCodecInternal.js +2 -0
  45. package/src/domain/services/ObserverView.js +6 -6
  46. package/src/domain/services/PatchBuilderV2.js +9 -9
  47. package/src/domain/services/PatchMessageCodec.js +1 -7
  48. package/src/domain/services/ProvenanceIndex.js +6 -8
  49. package/src/domain/services/ProvenancePayload.js +1 -2
  50. package/src/domain/services/QueryBuilder.js +29 -23
  51. package/src/domain/services/StateDiff.js +7 -7
  52. package/src/domain/services/StateSerializerV5.js +8 -6
  53. package/src/domain/services/StreamingBitmapIndexBuilder.js +29 -23
  54. package/src/domain/services/SyncAuthService.js +396 -0
  55. package/src/domain/services/SyncProtocol.js +23 -26
  56. package/src/domain/services/TemporalQuery.js +4 -3
  57. package/src/domain/services/TranslationCost.js +4 -4
  58. package/src/domain/services/WormholeService.js +19 -15
  59. package/src/domain/types/TickReceipt.js +10 -6
  60. package/src/domain/types/WarpTypesV2.js +2 -3
  61. package/src/domain/utils/CachedValue.js +1 -1
  62. package/src/domain/utils/LRUCache.js +3 -3
  63. package/src/domain/utils/MinHeap.js +2 -2
  64. package/src/domain/utils/RefLayout.js +19 -0
  65. package/src/domain/utils/WriterId.js +2 -2
  66. package/src/domain/utils/defaultCodec.js +9 -2
  67. package/src/domain/utils/defaultCrypto.js +36 -0
  68. package/src/domain/utils/roaring.js +5 -5
  69. package/src/domain/utils/seekCacheKey.js +32 -0
  70. package/src/domain/warp/PatchSession.js +3 -3
  71. package/src/domain/warp/Writer.js +2 -2
  72. package/src/infrastructure/adapters/BunHttpAdapter.js +21 -8
  73. package/src/infrastructure/adapters/CasSeekCacheAdapter.js +311 -0
  74. package/src/infrastructure/adapters/ClockAdapter.js +2 -2
  75. package/src/infrastructure/adapters/DenoHttpAdapter.js +22 -9
  76. package/src/infrastructure/adapters/GitGraphAdapter.js +25 -83
  77. package/src/infrastructure/adapters/InMemoryGraphAdapter.js +488 -0
  78. package/src/infrastructure/adapters/NodeCryptoAdapter.js +16 -3
  79. package/src/infrastructure/adapters/NodeHttpAdapter.js +33 -11
  80. package/src/infrastructure/adapters/WebCryptoAdapter.js +21 -11
  81. package/src/infrastructure/adapters/adapterValidation.js +90 -0
  82. package/src/infrastructure/codecs/CborCodec.js +16 -8
  83. package/src/ports/BlobPort.js +2 -2
  84. package/src/ports/CodecPort.js +2 -2
  85. package/src/ports/CommitPort.js +8 -21
  86. package/src/ports/ConfigPort.js +3 -3
  87. package/src/ports/CryptoPort.js +7 -7
  88. package/src/ports/GraphPersistencePort.js +12 -14
  89. package/src/ports/HttpServerPort.js +1 -5
  90. package/src/ports/IndexStoragePort.js +1 -0
  91. package/src/ports/LoggerPort.js +9 -9
  92. package/src/ports/RefPort.js +5 -5
  93. package/src/ports/SeekCachePort.js +73 -0
  94. package/src/ports/TreePort.js +3 -3
  95. package/src/visualization/layouts/converters.js +14 -7
  96. package/src/visualization/layouts/elkAdapter.js +17 -4
  97. package/src/visualization/layouts/elkLayout.js +23 -7
  98. package/src/visualization/layouts/index.js +3 -3
  99. package/src/visualization/renderers/ascii/check.js +30 -17
  100. package/src/visualization/renderers/ascii/graph.js +92 -1
  101. package/src/visualization/renderers/ascii/history.js +28 -26
  102. package/src/visualization/renderers/ascii/info.js +9 -7
  103. package/src/visualization/renderers/ascii/materialize.js +20 -16
  104. package/src/visualization/renderers/ascii/opSummary.js +15 -7
  105. package/src/visualization/renderers/ascii/path.js +1 -1
  106. package/src/visualization/renderers/ascii/seek.js +187 -23
  107. package/src/visualization/renderers/ascii/table.js +1 -1
  108. package/src/visualization/renderers/svg/index.js +5 -1
@@ -37,7 +37,7 @@ export default class DagTopology {
37
37
  * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger instance
38
38
  * @param {import('./DagTraversal.js').default} [options.traversal] - Traversal service for ancestor enumeration
39
39
  */
40
- constructor({ indexReader, logger = nullLogger, traversal } = {}) {
40
+ constructor(/** @type {{ indexReader: import('./BitmapIndexReader.js').default, logger?: import('../../ports/LoggerPort.js').default, traversal?: import('./DagTraversal.js').default }} */ { indexReader, logger = nullLogger, traversal } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
41
41
  if (!indexReader) {
42
42
  throw new Error('DagTopology requires an indexReader');
43
43
  }
@@ -76,9 +76,10 @@ export default class DagTopology {
76
76
  */
77
77
  async commonAncestors({ shas, maxResults = 100, maxDepth = DEFAULT_MAX_DEPTH, signal }) {
78
78
  if (shas.length === 0) { return []; }
79
+ const traversal = /** @type {import('./DagTraversal.js').default} */ (this._traversal);
79
80
  if (shas.length === 1) {
80
81
  const ancestors = [];
81
- for await (const node of this._traversal.ancestors({ sha: shas[0], maxNodes: maxResults, maxDepth, signal })) {
82
+ for await (const node of traversal.ancestors({ sha: shas[0], maxNodes: maxResults, maxDepth, signal })) {
82
83
  ancestors.push(node.sha);
83
84
  }
84
85
  return ancestors;
@@ -92,7 +93,7 @@ export default class DagTopology {
92
93
  for (const sha of shas) {
93
94
  checkAborted(signal, 'commonAncestors');
94
95
  const visited = new Set();
95
- for await (const node of this._traversal.ancestors({ sha, maxDepth, signal })) {
96
+ for await (const node of traversal.ancestors({ sha, maxDepth, signal })) {
96
97
  if (!visited.has(node.sha)) {
97
98
  visited.add(node.sha);
98
99
  ancestorCounts.set(node.sha, (ancestorCounts.get(node.sha) || 0) + 1);
@@ -149,7 +150,7 @@ export default class DagTopology {
149
150
  checkAborted(signal, 'topologicalSort');
150
151
  }
151
152
 
152
- const sha = queue.shift();
153
+ const sha = /** @type {string} */ (queue.shift());
153
154
  const neighbors = await this._getNeighbors(sha, direction);
154
155
  edges.set(sha, neighbors);
155
156
 
@@ -182,7 +183,7 @@ export default class DagTopology {
182
183
  checkAborted(signal, 'topologicalSort');
183
184
  }
184
185
 
185
- const sha = ready.shift();
186
+ const sha = /** @type {string} */ (ready.shift());
186
187
  const depth = depthMap.get(sha) || 0;
187
188
 
188
189
  nodesYielded++;
@@ -190,7 +191,7 @@ export default class DagTopology {
190
191
 
191
192
  const neighbors = edges.get(sha) || [];
192
193
  for (const neighbor of neighbors) {
193
- const newDegree = inDegree.get(neighbor) - 1;
194
+ const newDegree = /** @type {number} */ (inDegree.get(neighbor)) - 1;
194
195
  inDegree.set(neighbor, newDegree);
195
196
 
196
197
  if (!depthMap.has(neighbor)) {
@@ -43,7 +43,7 @@ export default class DagTraversal {
43
43
  * @param {import('./BitmapIndexReader.js').default} options.indexReader - Index reader for O(1) lookups
44
44
  * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger instance
45
45
  */
46
- constructor({ indexReader, logger = nullLogger } = {}) {
46
+ constructor(/** @type {{ indexReader: import('./BitmapIndexReader.js').default, logger?: import('../../ports/LoggerPort.js').default }} */ { indexReader, logger = nullLogger } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
47
47
  if (!indexReader) {
48
48
  throw new Error('DagTraversal requires an indexReader');
49
49
  }
@@ -89,6 +89,7 @@ export default class DagTraversal {
89
89
  signal,
90
90
  }) {
91
91
  const visited = new Set();
92
+ /** @type {TraversalNode[]} */
92
93
  const queue = [{ sha: start, depth: 0, parent: null }];
93
94
  let nodesYielded = 0;
94
95
 
@@ -99,7 +100,7 @@ export default class DagTraversal {
99
100
  checkAborted(signal, 'bfs');
100
101
  }
101
102
 
102
- const current = queue.shift();
103
+ const current = /** @type {TraversalNode} */ (queue.shift());
103
104
 
104
105
  if (visited.has(current.sha)) { continue; }
105
106
  if (current.depth > maxDepth) { continue; }
@@ -142,6 +143,7 @@ export default class DagTraversal {
142
143
  signal,
143
144
  }) {
144
145
  const visited = new Set();
146
+ /** @type {TraversalNode[]} */
145
147
  const stack = [{ sha: start, depth: 0, parent: null }];
146
148
  let nodesYielded = 0;
147
149
 
@@ -152,7 +154,7 @@ export default class DagTraversal {
152
154
  checkAborted(signal, 'dfs');
153
155
  }
154
156
 
155
- const current = stack.pop();
157
+ const current = /** @type {TraversalNode} */ (stack.pop());
156
158
 
157
159
  if (visited.has(current.sha)) { continue; }
158
160
  if (current.depth > maxDepth) { continue; }
@@ -50,12 +50,13 @@ export function getWriters(frontier) {
50
50
  * Keys are sorted for determinism.
51
51
  * @param {Frontier} frontier
52
52
  * @param {Object} [options]
53
- * @param {import('../../ports/CodecPort.js').default} options.codec - Codec for serialization
54
- * @returns {Buffer}
53
+ * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for serialization
54
+ * @returns {Buffer|Uint8Array}
55
55
  */
56
- export function serializeFrontier(frontier, { codec } = {}) {
56
+ export function serializeFrontier(frontier, { codec } = /** @type {{codec?: import('../../ports/CodecPort.js').default}} */ ({})) {
57
57
  const c = codec || defaultCodec;
58
58
  // Convert Map to sorted object for deterministic encoding
59
+ /** @type {Record<string, string|undefined>} */
59
60
  const obj = {};
60
61
  const sortedKeys = Array.from(frontier.keys()).sort();
61
62
  for (const key of sortedKeys) {
@@ -68,12 +69,12 @@ export function serializeFrontier(frontier, { codec } = {}) {
68
69
  * Deserializes frontier from CBOR bytes.
69
70
  * @param {Buffer} buffer
70
71
  * @param {Object} [options]
71
- * @param {import('../../ports/CodecPort.js').default} options.codec - Codec for deserialization
72
+ * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for deserialization
72
73
  * @returns {Frontier}
73
74
  */
74
- export function deserializeFrontier(buffer, { codec } = {}) {
75
+ export function deserializeFrontier(buffer, { codec } = /** @type {{codec?: import('../../ports/CodecPort.js').default}} */ ({})) {
75
76
  const c = codec || defaultCodec;
76
- const obj = c.decode(buffer);
77
+ const obj = /** @type {Record<string, string>} */ (c.decode(buffer));
77
78
  const frontier = new Map();
78
79
  for (const [writerId, patchSha] of Object.entries(obj)) {
79
80
  frontier.set(writerId, patchSha);
@@ -46,7 +46,7 @@ export default class HealthCheckService {
46
46
  /**
47
47
  * Creates a HealthCheckService instance.
48
48
  * @param {Object} options
49
- * @param {import('../../ports/GraphPersistencePort.js').default} options.persistence - Persistence port for repository checks
49
+ * @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/CommitPort.js').default} options.persistence - Persistence port for repository checks
50
50
  * @param {import('../../ports/ClockPort.js').default} options.clock - Clock port for timing operations
51
51
  * @param {number} [options.cacheTtlMs=5000] - How long to cache health results in milliseconds
52
52
  * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger for structured logging
@@ -132,22 +132,23 @@ export default class HealthCheckService {
132
132
  */
133
133
  async getHealth() {
134
134
  const { value, cachedAt, fromCache } = await this._healthCache.getWithMetadata();
135
+ const result = /** @type {HealthResult} */ (value);
135
136
 
136
137
  if (cachedAt) {
137
- return { ...value, cachedAt };
138
+ return { ...result, cachedAt };
138
139
  }
139
140
 
140
141
  // Log only for fresh computations
141
142
  if (!fromCache) {
142
143
  this._logger.debug('Health check completed', {
143
144
  operation: 'getHealth',
144
- status: value.status,
145
- repositoryStatus: value.components.repository.status,
146
- indexStatus: value.components.index.status,
145
+ status: result.status,
146
+ repositoryStatus: result.components.repository.status,
147
+ indexStatus: result.components.index.status,
147
148
  });
148
149
  }
149
150
 
150
- return value;
151
+ return result;
151
152
  }
152
153
 
153
154
  /**
@@ -184,16 +185,16 @@ export default class HealthCheckService {
184
185
  try {
185
186
  const pingResult = await this._persistence.ping();
186
187
  return {
187
- status: pingResult.ok ? HealthStatus.HEALTHY : HealthStatus.UNHEALTHY,
188
+ status: /** @type {'healthy'|'unhealthy'} */ (pingResult.ok ? HealthStatus.HEALTHY : HealthStatus.UNHEALTHY),
188
189
  latencyMs: Math.round(pingResult.latencyMs * 100) / 100, // Round to 2 decimal places
189
190
  };
190
191
  } catch (err) {
191
192
  this._logger.warn('Repository ping failed', {
192
193
  operation: 'checkRepository',
193
- error: err.message,
194
+ error: /** @type {any} */ (err).message, // TODO(ts-cleanup): type error
194
195
  });
195
196
  return {
196
- status: HealthStatus.UNHEALTHY,
197
+ status: /** @type {'healthy'|'unhealthy'} */ (HealthStatus.UNHEALTHY),
197
198
  latencyMs: 0,
198
199
  };
199
200
  }
@@ -207,7 +208,7 @@ export default class HealthCheckService {
207
208
  _checkIndex() {
208
209
  if (!this._indexReader) {
209
210
  return {
210
- status: HealthStatus.DEGRADED,
211
+ status: /** @type {'healthy'|'degraded'|'unhealthy'} */ (HealthStatus.DEGRADED),
211
212
  loaded: false,
212
213
  };
213
214
  }
@@ -216,7 +217,7 @@ export default class HealthCheckService {
216
217
  const shardCount = this._indexReader.shardOids?.size ?? 0;
217
218
 
218
219
  return {
219
- status: HealthStatus.HEALTHY,
220
+ status: /** @type {'healthy'|'degraded'|'unhealthy'} */ (HealthStatus.HEALTHY),
220
221
  loaded: true,
221
222
  shardCount,
222
223
  };
@@ -232,15 +233,15 @@ export default class HealthCheckService {
232
233
  _computeOverallStatus(repositoryHealth, indexHealth) {
233
234
  // If repository is unhealthy, overall is unhealthy
234
235
  if (repositoryHealth.status === HealthStatus.UNHEALTHY) {
235
- return HealthStatus.UNHEALTHY;
236
+ return /** @type {'healthy'|'degraded'|'unhealthy'} */ (HealthStatus.UNHEALTHY);
236
237
  }
237
238
 
238
239
  // If index is degraded (not loaded), overall is degraded
239
240
  if (indexHealth.status === HealthStatus.DEGRADED) {
240
- return HealthStatus.DEGRADED;
241
+ return /** @type {'healthy'|'degraded'|'unhealthy'} */ (HealthStatus.DEGRADED);
241
242
  }
242
243
 
243
244
  // All components healthy
244
- return HealthStatus.HEALTHY;
245
+ return /** @type {'healthy'|'degraded'|'unhealthy'} */ (HealthStatus.HEALTHY);
245
246
  }
246
247
  }
@@ -7,6 +7,21 @@
7
7
  * @module domain/services/HookInstaller
8
8
  */
9
9
 
10
+ /**
11
+ * @typedef {Object} FsAdapter
12
+ * @property {(path: string, content: string | Buffer, options?: Object) => void} writeFileSync
13
+ * @property {(path: string, mode: number) => void} chmodSync
14
+ * @property {(path: string, encoding?: string) => string} readFileSync
15
+ * @property {(path: string) => boolean} existsSync
16
+ * @property {(path: string, options?: Object) => void} mkdirSync
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} PathUtils
21
+ * @property {(...segments: string[]) => string} join
22
+ * @property {(...segments: string[]) => string} resolve
23
+ */
24
+
10
25
  const DELIMITER_START_PREFIX = '# --- @git-stunts/git-warp post-merge hook';
11
26
  const DELIMITER_END = '# --- end @git-stunts/git-warp ---';
12
27
  const VERSION_MARKER_PREFIX = '# warp-hook-version:';
@@ -59,17 +74,22 @@ export class HookInstaller {
59
74
  * Creates a new HookInstaller.
60
75
  *
61
76
  * @param {Object} deps - Injected dependencies
62
- * @param {Object} deps.fs - Filesystem adapter with methods: readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, copyFileSync
77
+ * @param {FsAdapter} deps.fs - Filesystem adapter with methods: readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync
63
78
  * @param {(repoPath: string, key: string) => string|null} deps.execGitConfig - Function to read git config values
64
79
  * @param {string} deps.version - Package version
65
80
  * @param {string} deps.templateDir - Directory containing hook templates
66
- * @param {{ join: (...segments: string[]) => string, resolve: (...segments: string[]) => string }} deps.path - Path utilities (join and resolve)
81
+ * @param {PathUtils} deps.path - Path utilities (join and resolve)
67
82
  */
68
- constructor({ fs, execGitConfig, version, templateDir, path } = {}) {
83
+ constructor({ fs, execGitConfig, version, templateDir, path } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
84
+ /** @type {FsAdapter} */
69
85
  this._fs = fs;
86
+ /** @type {(repoPath: string, key: string) => string|null} */
70
87
  this._execGitConfig = execGitConfig;
88
+ /** @type {string} */
71
89
  this._templateDir = templateDir;
90
+ /** @type {string} */
72
91
  this._version = version;
92
+ /** @type {PathUtils} */
73
93
  this._path = path;
74
94
  }
75
95
 
@@ -134,7 +154,11 @@ export class HookInstaller {
134
154
  throw new Error(`Unknown install strategy: ${strategy}`);
135
155
  }
136
156
 
137
- /** @private */
157
+ /**
158
+ * @param {string} hookPath
159
+ * @param {string} content
160
+ * @private
161
+ */
138
162
  _freshInstall(hookPath, content) {
139
163
  this._fs.writeFileSync(hookPath, content, { mode: 0o755 });
140
164
  this._fs.chmodSync(hookPath, 0o755);
@@ -145,13 +169,17 @@ export class HookInstaller {
145
169
  };
146
170
  }
147
171
 
148
- /** @private */
172
+ /**
173
+ * @param {string} hookPath
174
+ * @param {string} stamped
175
+ * @private
176
+ */
149
177
  _upgradeInstall(hookPath, stamped) {
150
178
  const existing = this._readFile(hookPath);
151
179
  const classification = classifyExistingHook(existing);
152
180
 
153
181
  if (classification.appended) {
154
- const updated = replaceDelimitedSection(existing, stamped);
182
+ const updated = replaceDelimitedSection(/** @type {string} */ (existing), stamped);
155
183
  // If delimiters were corrupted, replaceDelimitedSection returns unchanged content — fall back to overwrite
156
184
  if (updated === existing) {
157
185
  this._fs.writeFileSync(hookPath, stamped, { mode: 0o755 });
@@ -170,7 +198,11 @@ export class HookInstaller {
170
198
  };
171
199
  }
172
200
 
173
- /** @private */
201
+ /**
202
+ * @param {string} hookPath
203
+ * @param {string} stamped
204
+ * @private
205
+ */
174
206
  _appendInstall(hookPath, stamped) {
175
207
  const existing = this._readFile(hookPath) || '';
176
208
  const body = stripShebang(stamped);
@@ -184,7 +216,11 @@ export class HookInstaller {
184
216
  };
185
217
  }
186
218
 
187
- /** @private */
219
+ /**
220
+ * @param {string} hookPath
221
+ * @param {string} stamped
222
+ * @private
223
+ */
188
224
  _replaceInstall(hookPath, stamped) {
189
225
  const existing = this._readFile(hookPath);
190
226
  let backupPath;
@@ -210,12 +246,18 @@ export class HookInstaller {
210
246
  return this._fs.readFileSync(templatePath, 'utf8');
211
247
  }
212
248
 
213
- /** @private */
249
+ /**
250
+ * @param {string} template
251
+ * @private
252
+ */
214
253
  _stampVersion(template) {
215
254
  return template.replaceAll(VERSION_PLACEHOLDER, this._version);
216
255
  }
217
256
 
218
- /** @private */
257
+ /**
258
+ * @param {string} repoPath
259
+ * @private
260
+ */
219
261
  _resolveHooksDir(repoPath) {
220
262
  const customPath = this._execGitConfig(repoPath, 'core.hooksPath');
221
263
  if (customPath) {
@@ -230,12 +272,18 @@ export class HookInstaller {
230
272
  return this._path.join(repoPath, '.git', 'hooks');
231
273
  }
232
274
 
233
- /** @private */
275
+ /**
276
+ * @param {string} repoPath
277
+ * @private
278
+ */
234
279
  _resolveHookPath(repoPath) {
235
280
  return this._path.join(this._resolveHooksDir(repoPath), 'post-merge');
236
281
  }
237
282
 
238
- /** @private */
283
+ /**
284
+ * @param {string} filePath
285
+ * @private
286
+ */
239
287
  _readFile(filePath) {
240
288
  try {
241
289
  return this._fs.readFileSync(filePath, 'utf8');
@@ -244,7 +292,10 @@ export class HookInstaller {
244
292
  }
245
293
  }
246
294
 
247
- /** @private */
295
+ /**
296
+ * @param {string} dirPath
297
+ * @private
298
+ */
248
299
  _ensureDir(dirPath) {
249
300
  if (!this._fs.existsSync(dirPath)) {
250
301
  this._fs.mkdirSync(dirPath, { recursive: true });
@@ -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
  /**
@@ -22,9 +24,10 @@ function canonicalizeJson(value) {
22
24
  return value.map(canonicalizeJson);
23
25
  }
24
26
  if (value && typeof value === 'object') {
27
+ /** @type {{ [x: string]: * }} */
25
28
  const sorted = {};
26
29
  for (const key of Object.keys(value).sort()) {
27
- sorted[key] = canonicalizeJson(value[key]);
30
+ sorted[key] = canonicalizeJson(/** @type {{ [x: string]: * }} */ (value)[key]);
28
31
  }
29
32
  return sorted;
30
33
  }
@@ -97,7 +100,7 @@ function isValidSyncRequest(parsed) {
97
100
  * Checks the content-type header. Returns an error response if the
98
101
  * content type is present but not application/json, otherwise null.
99
102
  *
100
- * @param {Object} headers - Request headers
103
+ * @param {{ [x: string]: string }} headers - Request headers
101
104
  * @returns {{ status: number, headers: Object, body: string }|null}
102
105
  * @private
103
106
  */
@@ -113,7 +116,7 @@ function checkContentType(headers) {
113
116
  * Parses the request URL and validates the path and method.
114
117
  * Returns an error response on failure, or null if valid.
115
118
  *
116
- * @param {{ method: string, url: string, headers: Object }} request
119
+ * @param {{ method: string, url: string, headers: { [x: string]: string } }} request
117
120
  * @param {string} expectedPath
118
121
  * @param {string} defaultHost
119
122
  * @returns {{ status: number, headers: Object, body: string }|null}
@@ -139,18 +142,28 @@ function validateRoute(request, expectedPath, defaultHost) {
139
142
  }
140
143
 
141
144
  /**
142
- * Parses and validates the request body as a sync request.
145
+ * Checks if the request body exceeds the maximum allowed size.
143
146
  *
144
147
  * @param {Buffer|undefined} body
145
148
  * @param {number} maxBytes
146
- * @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
147
150
  * @private
148
151
  */
149
- function parseBody(body, maxBytes) {
152
+ function checkBodySize(body, maxBytes) {
150
153
  if (body && body.length > maxBytes) {
151
- return { error: errorResponse(413, 'Request too large'), parsed: null };
154
+ return errorResponse(413, 'Request too large');
152
155
  }
156
+ return null;
157
+ }
153
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) {
154
167
  const bodyStr = body ? body.toString('utf-8') : '';
155
168
 
156
169
  let parsed;
@@ -167,31 +180,77 @@ function parseBody(body, maxBytes) {
167
180
  return { error: null, parsed };
168
181
  }
169
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
+
170
202
  export default class HttpSyncServer {
171
203
  /**
172
204
  * @param {Object} options
173
205
  * @param {import('../../ports/HttpServerPort.js').default} options.httpPort - HTTP server port abstraction
174
- * @param {Object} options.graph - WarpGraph instance (must expose processSyncRequest)
206
+ * @param {{ processSyncRequest: (request: *) => Promise<*> }} options.graph - WarpGraph instance (must expose processSyncRequest)
175
207
  * @param {string} [options.path='/sync'] - URL path to handle sync requests on
176
208
  * @param {string} [options.host='127.0.0.1'] - Host to bind
177
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
178
211
  */
179
- constructor({ httpPort, graph, path = '/sync', host = '127.0.0.1', maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES } = {}) {
212
+ constructor({ httpPort, graph, path = '/sync', host = '127.0.0.1', maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES, auth } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
180
213
  this._httpPort = httpPort;
181
214
  this._graph = graph;
182
215
  this._path = path && path.startsWith('/') ? path : `/${path || 'sync'}`;
183
216
  this._host = host;
184
217
  this._maxRequestBytes = maxRequestBytes;
185
218
  this._server = null;
219
+ const authInit = initAuth(auth);
220
+ this._auth = authInit.auth;
221
+ this._authMode = authInit.authMode;
186
222
  }
187
223
 
188
224
  /**
189
225
  * Handles an incoming HTTP request through the port abstraction.
190
226
  *
191
- * @param {{ method: string, url: string, headers: Object, body: Buffer|undefined }} request
227
+ * @param {{ method: string, url: string, headers: { [x: string]: string }, body: Buffer|undefined }} request
192
228
  * @returns {Promise<{ status: number, headers: Object, body: string }>}
193
229
  * @private
194
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 */
195
254
  async _handleRequest(request) {
196
255
  const contentTypeError = checkContentType(request.headers);
197
256
  if (contentTypeError) {
@@ -203,7 +262,17 @@ export default class HttpSyncServer {
203
262
  return routeError;
204
263
  }
205
264
 
206
- 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);
207
276
  if (error) {
208
277
  return error;
209
278
  }
@@ -212,7 +281,7 @@ export default class HttpSyncServer {
212
281
  const response = await this._graph.processSyncRequest(parsed);
213
282
  return jsonResponse(response);
214
283
  } catch (err) {
215
- return errorResponse(500, err?.message || 'Sync failed');
284
+ return errorResponse(500, /** @type {any} */ (err)?.message || 'Sync failed'); // TODO(ts-cleanup): type error
216
285
  }
217
286
  }
218
287
 
@@ -228,18 +297,18 @@ export default class HttpSyncServer {
228
297
  throw new Error('listen() requires a numeric port');
229
298
  }
230
299
 
231
- const server = this._httpPort.createServer((request) => this._handleRequest(request));
300
+ const server = this._httpPort.createServer((/** @type {*} */ request) => this._handleRequest(request)); // TODO(ts-cleanup): type http callback
232
301
  this._server = server;
233
302
 
234
- await new Promise((resolve, reject) => {
235
- server.listen(port, this._host, (err) => {
303
+ await /** @type {Promise<void>} */ (new Promise((resolve, reject) => {
304
+ server.listen(port, this._host, (/** @type {*} */ err) => { // TODO(ts-cleanup): type http callback
236
305
  if (err) {
237
306
  reject(err);
238
307
  } else {
239
308
  resolve();
240
309
  }
241
310
  });
242
- });
311
+ }));
243
312
 
244
313
  const address = server.address();
245
314
  const actualPort = typeof address === 'object' && address ? address.port : port;
@@ -248,15 +317,15 @@ export default class HttpSyncServer {
248
317
  return {
249
318
  url,
250
319
  close: () =>
251
- new Promise((resolve, reject) => {
252
- server.close((err) => {
320
+ /** @type {Promise<void>} */ (new Promise((resolve, reject) => {
321
+ server.close((/** @type {*} */ err) => { // TODO(ts-cleanup): type http callback
253
322
  if (err) {
254
323
  reject(err);
255
324
  } else {
256
325
  resolve();
257
326
  }
258
327
  });
259
- }),
328
+ })),
260
329
  };
261
330
  }
262
331
  }