-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.js
More file actions
498 lines (453 loc) · 20.5 KB
/
index.js
File metadata and controls
498 lines (453 loc) · 20.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
/* @ts-self-types="./index.d.ts" */
/**
* @fileoverview Content Addressable Store - Managed blob storage in Git.
*/
// ---------------------------------------------------------------------------
// Imports used in the class body
// ---------------------------------------------------------------------------
import CasService from './src/domain/services/CasService.js';
import VaultService from './src/domain/services/VaultService.js';
import rotateVaultPassphrase from './src/domain/services/rotateVaultPassphrase.js';
import GitPersistenceAdapter from './src/infrastructure/adapters/GitPersistenceAdapter.js';
import GitRefAdapter from './src/infrastructure/adapters/GitRefAdapter.js';
import createCryptoAdapter from './src/infrastructure/adapters/createCryptoAdapter.js';
import { storeFile, restoreFile } from './src/infrastructure/adapters/FileIOHelper.js';
import JsonCodec from './src/infrastructure/codecs/JsonCodec.js';
import CborCodec from './src/infrastructure/codecs/CborCodec.js';
import SilentObserver from './src/infrastructure/adapters/SilentObserver.js';
import resolveChunker from './src/infrastructure/chunkers/resolveChunker.js';
// ---------------------------------------------------------------------------
// Re-exports — modules used in the class body
// ---------------------------------------------------------------------------
export {
CasService,
VaultService,
GitPersistenceAdapter,
GitRefAdapter,
JsonCodec,
CborCodec,
SilentObserver,
};
// ---------------------------------------------------------------------------
// Re-exports — barrel-only (no local binding needed)
// ---------------------------------------------------------------------------
export { default as NodeCryptoAdapter } from './src/infrastructure/adapters/NodeCryptoAdapter.js';
export { default as CryptoPort } from './src/ports/CryptoPort.js';
export { default as ChunkingPort } from './src/ports/ChunkingPort.js';
export { default as ObservabilityPort } from './src/ports/ObservabilityPort.js';
export { default as Manifest } from './src/domain/value-objects/Manifest.js';
export { default as Chunk } from './src/domain/value-objects/Chunk.js';
export { default as EventEmitterObserver } from './src/infrastructure/adapters/EventEmitterObserver.js';
export { default as StatsCollector } from './src/infrastructure/adapters/StatsCollector.js';
export { default as FixedChunker } from './src/infrastructure/chunkers/FixedChunker.js';
export { default as CdcChunker } from './src/infrastructure/chunkers/CdcChunker.js';
/**
* High-level facade for the Content Addressable Store library.
*
* Wraps {@link CasService} and {@link VaultService} with lazy initialization,
* runtime-adaptive crypto selection, and convenience helpers for file I/O.
*/
export default class ContentAddressableStore {
/**
* @param {Object} options
* @param {import('@git-stunts/plumbing').default} options.plumbing - GitPlumbing instance for Git operations.
* @param {number} [options.chunkSize] - Chunk size in bytes (default 256 KiB).
* @param {import('./src/ports/CodecPort.js').default} [options.codec] - Manifest codec (default JsonCodec).
* @param {import('./src/ports/CryptoPort.js').default} [options.crypto] - Crypto adapter (auto-detected if omitted).
* @param {import('./src/ports/ObservabilityPort.js').default} [options.observability] - Observability adapter (SilentObserver if omitted).
* @param {import('@git-stunts/alfred').Policy} [options.policy] - Resilience policy for Git I/O.
* @param {number} [options.merkleThreshold=1000] - Chunk count threshold for Merkle manifests.
* @param {number} [options.concurrency=1] - Maximum parallel chunk I/O operations.
* @param {{ strategy: string, chunkSize?: number, targetChunkSize?: number, minChunkSize?: number, maxChunkSize?: number }} [options.chunking] - Chunking strategy config.
* @param {import('./src/ports/ChunkingPort.js').default} [options.chunker] - Pre-built ChunkingPort instance (advanced).
* @param {number} [options.maxRestoreBufferSize=536870912] - Max buffered restore size in bytes for encrypted/compressed restores (default 512 MiB).
*/
constructor({ plumbing, chunkSize, codec, policy, crypto, observability, merkleThreshold, concurrency, chunking, chunker, maxRestoreBufferSize }) {
this.#config = { plumbing, chunkSize, codec, policy, crypto, observability, merkleThreshold, concurrency, chunking, chunker, maxRestoreBufferSize };
this.service = null;
this.#servicePromise = null;
}
/** @type {{ plumbing: *, chunkSize?: number, codec?: *, policy?: *, crypto?: *, observability?: *, merkleThreshold?: number, concurrency?: number, chunking?: *, chunker?: *, maxRestoreBufferSize?: number }} */
#config;
/** @type {VaultService|null} */
#vault = null;
#servicePromise = null;
/**
* Lazily initializes the service, handling async adapter discovery.
* @private
* @returns {Promise<CasService>}
*/
async #getService() {
if (!this.#servicePromise) {
this.#servicePromise = this.#initService();
}
return await this.#servicePromise;
}
/**
* Constructs adapters, resolves crypto, and creates CasService + VaultService.
* @private
* @returns {Promise<CasService>}
*/
async #initService() {
const cfg = this.#config;
const persistence = new GitPersistenceAdapter({
plumbing: cfg.plumbing,
policy: cfg.policy,
});
const crypto = cfg.crypto || await createCryptoAdapter();
const chunker = resolveChunker({ chunker: cfg.chunker, chunking: cfg.chunking });
this.service = new CasService({
persistence,
chunkSize: cfg.chunkSize,
codec: cfg.codec || new JsonCodec(),
crypto,
observability: cfg.observability || new SilentObserver(),
merkleThreshold: cfg.merkleThreshold,
concurrency: cfg.concurrency,
chunker,
maxRestoreBufferSize: cfg.maxRestoreBufferSize,
});
const ref = new GitRefAdapter({
plumbing: cfg.plumbing,
policy: cfg.policy,
});
this.#vault = new VaultService({ persistence, ref, crypto, observability: this.service.observability });
return this.service;
}
/**
* Lazily initializes and returns the underlying {@link VaultService}.
* @private
* @returns {Promise<VaultService>}
*/
async #getVault() {
await this.#getService();
return this.#vault;
}
/**
* Lazily initializes and returns the underlying {@link CasService}.
* @returns {Promise<CasService>}
*/
async getService() {
return await this.#getService();
}
/**
* Lazily initializes and returns the underlying {@link VaultService}.
* @returns {Promise<VaultService>}
*/
async getVaultService() {
return await this.#getVault();
}
/**
* Factory to create a CAS with JSON codec.
* @param {Object} options
* @param {import('@git-stunts/plumbing').default} options.plumbing - GitPlumbing instance.
* @param {number} [options.chunkSize] - Chunk size in bytes.
* @param {import('@git-stunts/alfred').Policy} [options.policy] - Resilience policy.
* @returns {ContentAddressableStore}
*/
static createJson({ plumbing, chunkSize, policy }) {
return new ContentAddressableStore({ plumbing, chunkSize, codec: new JsonCodec(), policy });
}
/**
* Factory to create a CAS with CBOR codec.
* @param {Object} options
* @param {import('@git-stunts/plumbing').default} options.plumbing - GitPlumbing instance.
* @param {number} [options.chunkSize] - Chunk size in bytes.
* @param {import('@git-stunts/alfred').Policy} [options.policy] - Resilience policy.
* @returns {ContentAddressableStore}
*/
static createCbor({ plumbing, chunkSize, policy }) {
return new ContentAddressableStore({ plumbing, chunkSize, codec: new CborCodec(), policy });
}
/**
* Returns the configured chunk size in bytes.
* @returns {number}
*/
get chunkSize() {
return this.service?.chunkSize || this.#config.chunkSize || 256 * 1024;
}
/**
* Encrypts a buffer using AES-256-GCM.
* @param {Object} options
* @param {Buffer} options.buffer - Plaintext data to encrypt.
* @param {Buffer} options.key - 32-byte encryption key.
* @returns {Promise<{ buf: Buffer, meta: { algorithm: string, nonce: string, tag: string, encrypted: boolean } }>}
*/
async encrypt(options) {
const service = await this.#getService();
return await service.encrypt(options);
}
/**
* Decrypts a buffer. Returns it unchanged if `meta.encrypted` is falsy.
* @param {Object} options
* @param {Buffer} options.buffer - Ciphertext to decrypt.
* @param {Buffer} options.key - 32-byte encryption key.
* @param {{ encrypted: boolean, algorithm: string, nonce: string, tag: string }} options.meta - Encryption metadata.
* @returns {Promise<Buffer>}
*/
async decrypt(options) {
const service = await this.#getService();
return await service.decrypt(options);
}
/**
* Reads a file from disk and stores it in Git as chunked blobs.
* @param {Object} options
* @param {string} options.filePath - Absolute or relative path to the file.
* @param {string} options.slug - Logical identifier for the stored asset.
* @param {string} [options.filename] - Override filename (defaults to basename of filePath).
* @param {Buffer} [options.encryptionKey] - 32-byte key for AES-256-GCM encryption.
* @param {string} [options.passphrase] - Derive encryption key from passphrase.
* @param {Object} [options.kdfOptions] - KDF options when using passphrase.
* @param {{ algorithm: 'gzip' }} [options.compression] - Enable compression.
* @param {Array<{label: string, key: Buffer}>} [options.recipients] - Envelope recipients (mutually exclusive with encryptionKey/passphrase).
* @returns {Promise<import('./src/domain/value-objects/Manifest.js').default>} The resulting manifest.
*/
async storeFile(options) {
const service = await this.#getService();
return await storeFile(service, options);
}
/**
* Stores an async iterable source in Git as chunked blobs.
* @param {Object} options
* @param {AsyncIterable<Buffer>} options.source - Data to store.
* @param {string} options.slug - Logical identifier for the stored asset.
* @param {string} options.filename - Filename for the manifest.
* @param {Buffer} [options.encryptionKey] - 32-byte key for AES-256-GCM encryption.
* @param {string} [options.passphrase] - Derive encryption key from passphrase.
* @param {Object} [options.kdfOptions] - KDF options when using passphrase.
* @param {{ algorithm: 'gzip' }} [options.compression] - Enable compression.
* @param {Array<{label: string, key: Buffer}>} [options.recipients] - Envelope recipients (mutually exclusive with encryptionKey/passphrase).
* @returns {Promise<import('./src/domain/value-objects/Manifest.js').default>} The resulting manifest.
*/
async store(options) {
const service = await this.#getService();
return await service.store(options);
}
/**
* Restores a file from its manifest and writes it to disk.
* @param {Object} options
* @param {import('./src/domain/value-objects/Manifest.js').default} options.manifest - The file manifest.
* @param {Buffer} [options.encryptionKey] - 32-byte key, required if manifest is encrypted.
* @param {string} [options.passphrase] - Passphrase for KDF-based decryption.
* @param {string} options.outputPath - Destination file path.
* @returns {Promise<{ bytesWritten: number }>}
*/
async restoreFile(options) {
const service = await this.#getService();
return await restoreFile(service, options);
}
/**
* Restores a file from its manifest, returning the buffer directly.
* @param {Object} options
* @param {import('./src/domain/value-objects/Manifest.js').default} options.manifest - The file manifest.
* @param {Buffer} [options.encryptionKey] - 32-byte key, required if manifest is encrypted.
* @param {string} [options.passphrase] - Passphrase for KDF-based decryption.
* @returns {Promise<{ buffer: Buffer, bytesWritten: number }>}
*/
async restore(options) {
const service = await this.#getService();
return await service.restore(options);
}
/**
* Restores a file from its manifest as an async iterable of Buffer chunks.
* @param {Object} options
* @param {import('./src/domain/value-objects/Manifest.js').default} options.manifest - The file manifest.
* @param {Buffer} [options.encryptionKey] - 32-byte key, required if manifest is encrypted.
* @param {string} [options.passphrase] - Passphrase for KDF-based decryption.
* @returns {AsyncIterable<Buffer>}
*/
async *restoreStream(options) {
const service = await this.#getService();
yield* service.restoreStream(options);
}
/**
* Creates a Git tree object from a manifest.
* @param {Object} options
* @param {import('./src/domain/value-objects/Manifest.js').default} options.manifest - The file manifest.
* @returns {Promise<string>} Git OID of the created tree.
*/
async createTree(options) {
const service = await this.#getService();
return await service.createTree(options);
}
/**
* Verifies the integrity of a stored file by re-hashing its chunks.
* @param {import('./src/domain/value-objects/Manifest.js').default} manifest - The file manifest.
* @returns {Promise<boolean>} `true` if all chunks pass verification.
*/
async verifyIntegrity(manifest) {
const service = await this.#getService();
return await service.verifyIntegrity(manifest);
}
/**
* Reads a manifest from a Git tree OID.
* @param {Object} options
* @param {string} options.treeOid - Git tree OID to read the manifest from.
* @returns {Promise<import('./src/domain/value-objects/Manifest.js').default>}
*/
async readManifest(options) {
const service = await this.#getService();
return await service.readManifest(options);
}
/**
* Reads a manifest from a Git tree and returns inspection metadata.
* @param {Object} options
* @param {string} options.treeOid - Git tree OID of the asset.
* @returns {Promise<{ slug: string, chunksOrphaned: number }>}
*/
async inspectAsset(options) {
const service = await this.#getService();
return await service.inspectAsset(options);
}
/**
* @deprecated Use {@link inspectAsset} instead.
* @param {Object} options
* @param {string} options.treeOid - Git tree OID of the asset.
* @returns {Promise<{ slug: string, chunksOrphaned: number }>}
*/
async deleteAsset(options) {
const service = await this.#getService();
return await service.deleteAsset(options);
}
/**
* Aggregates referenced chunk blob OIDs across multiple stored assets.
* @param {Object} options
* @param {string[]} options.treeOids - Git tree OIDs to analyze.
* @returns {Promise<{ referenced: Set<string>, total: number }>}
*/
async collectReferencedChunks(options) {
const service = await this.#getService();
return await service.collectReferencedChunks(options);
}
/**
* @deprecated Use {@link collectReferencedChunks} instead.
* @param {Object} options
* @param {string[]} options.treeOids - Git tree OIDs to analyze.
* @returns {Promise<{ referenced: Set<string>, total: number }>}
*/
async findOrphanedChunks(options) {
const service = await this.#getService();
return await service.findOrphanedChunks(options);
}
/**
* Derives an encryption key from a passphrase using PBKDF2 or scrypt.
* @param {Object} options
* @param {string} options.passphrase - The passphrase.
* @param {Buffer} [options.salt] - Salt (random if omitted).
* @param {'pbkdf2'|'scrypt'} [options.algorithm='pbkdf2'] - KDF algorithm.
* @param {number} [options.iterations] - PBKDF2 iterations.
* @param {number} [options.cost] - scrypt cost (N).
* @param {number} [options.blockSize] - scrypt block size (r).
* @param {number} [options.parallelization] - scrypt parallelization (p).
* @param {number} [options.keyLength=32] - Derived key length.
* @returns {Promise<{ key: Buffer, salt: Buffer, params: Object }>}
*/
async deriveKey(options) {
const service = await this.#getService();
return await service.deriveKey(options);
}
// ---------------------------------------------------------------------------
// Recipient management — delegates to CasService
// ---------------------------------------------------------------------------
/**
* Adds a recipient to an envelope-encrypted manifest.
* @param {Object} options
* @param {import('./src/domain/value-objects/Manifest.js').default} options.manifest
* @param {Buffer} options.existingKey - KEK of an existing recipient.
* @param {Buffer} options.newRecipientKey - KEK for the new recipient.
* @param {string} options.label - Label for the new recipient.
* @returns {Promise<import('./src/domain/value-objects/Manifest.js').default>}
*/
async addRecipient(options) {
const service = await this.#getService();
return await service.addRecipient(options);
}
/**
* Removes a recipient from an envelope-encrypted manifest.
* @param {Object} options
* @param {import('./src/domain/value-objects/Manifest.js').default} options.manifest
* @param {string} options.label - Label to remove.
* @returns {Promise<import('./src/domain/value-objects/Manifest.js').default>}
*/
async removeRecipient(options) {
const service = await this.#getService();
return await service.removeRecipient(options);
}
/**
* Lists recipient labels from an envelope-encrypted manifest.
* @param {import('./src/domain/value-objects/Manifest.js').default} manifest
* @returns {Promise<string[]>}
*/
async listRecipients(manifest) {
const service = await this.#getService();
return service.listRecipients(manifest);
}
/**
* Rotates a recipient's key without re-encrypting data blobs.
* @param {Object} options
* @param {import('./src/domain/value-objects/Manifest.js').default} options.manifest
* @param {Buffer} options.oldKey - Current KEK of the recipient to rotate.
* @param {Buffer} options.newKey - New KEK to wrap the DEK with.
* @param {string} [options.label] - If provided, only rotate the named recipient.
* @returns {Promise<import('./src/domain/value-objects/Manifest.js').default>}
*/
async rotateKey(options) {
const service = await this.#getService();
return await service.rotateKey(options);
}
// ---------------------------------------------------------------------------
// Vault — delegates to VaultService
// ---------------------------------------------------------------------------
static VAULT_REF = VaultService.VAULT_REF;
/** @see VaultService#initVault */
async initVault(options) {
const vault = await this.#getVault();
return vault.initVault(options);
}
/** @see VaultService#addToVault */
async addToVault(options) {
const vault = await this.#getVault();
return vault.addToVault(options);
}
/** @see VaultService#listVault */
async listVault() {
const vault = await this.#getVault();
return vault.listVault();
}
/** @see VaultService#removeFromVault */
async removeFromVault(options) {
const vault = await this.#getVault();
return vault.removeFromVault(options);
}
/** @see VaultService#resolveVaultEntry */
async resolveVaultEntry(options) {
const vault = await this.#getVault();
return vault.resolveVaultEntry(options);
}
/** @see VaultService#getVaultMetadata */
async getVaultMetadata() {
const vault = await this.#getVault();
return vault.getVaultMetadata();
}
// ---------------------------------------------------------------------------
// Key rotation — orchestrates CasService + VaultService
// ---------------------------------------------------------------------------
/**
* Rotates the vault-level passphrase. Re-wraps every envelope-encrypted
* entry's DEK with a new KEK derived from `newPassphrase`. Entries using
* direct-key encryption are skipped.
*
* @param {Object} options
* @param {string} options.oldPassphrase - Current vault passphrase.
* @param {string} options.newPassphrase - New vault passphrase.
* @param {Object} [options.kdfOptions] - KDF options for new passphrase.
* @param {number} [options.maxRetries=3] - Maximum optimistic-concurrency retries on VAULT_CONFLICT.
* @param {number} [options.retryBaseMs=50] - Base delay in ms for exponential backoff between retries.
* @returns {Promise<{ commitOid: string, rotatedSlugs: string[], skippedSlugs: string[] }>}
*/
async rotateVaultPassphrase(options) {
const service = await this.#getService();
const vault = await this.#getVault();
return await rotateVaultPassphrase({ service, vault }, options);
}
}