From 80229d480ff76588b8d7002c795395739138d09e Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 28 Feb 2026 09:59:10 -0800 Subject: [PATCH 1/4] jsdoc: add strict type annotations across ports, adapters, and services - Fix all JSDoc @param names to match underscore-prefixed abstract params - Add EncryptionMeta, KdfParamSet, DeriveKeyParams typedefs to CryptoPort - Widen port return types to allow sync|async (sha256, encryptBuffer, etc.) - Add full @param/@returns annotations to all adapter overrides - Add VaultMetadata, VaultState, VaultEncryptionMeta typedefs to VaultService - Remove @private on #private members (TS18010) - Type Semaphore#queue as Array<() => void> - Type StatsCollector#startTime as number|null - Use (...args: unknown[]) => void for EventEmitter listeners - Create ambient.d.ts for @git-stunts/plumbing and bun modules - Add tsconfig.checkjs.json with strict checkJs enabled - Install @types/node for typecheck (dev only) Result: tsc --checkJs passes with 0 errors (was 342), eslint 0 errors, all 785 tests pass. --- package.json | 1 + pnpm-lock.yaml | 38 +++++--- src/domain/services/Semaphore.js | 4 +- src/domain/services/VaultService.js | 65 ++++++++++---- .../adapters/BunCryptoAdapter.js | 44 ++++++++-- .../adapters/EventEmitterObserver.js | 23 +++-- .../adapters/GitPersistenceAdapter.js | 26 ++++-- src/infrastructure/adapters/GitRefAdapter.js | 32 +++++-- .../adapters/NodeCryptoAdapter.js | 43 ++++++++-- src/infrastructure/adapters/SilentObserver.js | 15 ++++ src/infrastructure/adapters/StatsCollector.js | 16 +++- .../adapters/WebCryptoAdapter.js | 86 +++++++++++++++---- src/infrastructure/chunkers/FixedChunker.js | 6 +- src/infrastructure/codecs/CborCodec.js | 14 ++- src/infrastructure/codecs/JsonCodec.js | 12 ++- src/ports/CodecPort.js | 6 +- src/ports/CryptoPort.js | 77 ++++++++++++----- src/ports/GitPersistencePort.js | 8 +- src/ports/GitRefPort.js | 20 ++--- src/types/ambient.d.ts | 28 ++++++ tsconfig.checkjs.json | 34 ++++++++ 21 files changed, 475 insertions(+), 123 deletions(-) create mode 100644 src/types/ambient.d.ts create mode 100644 tsconfig.checkjs.json diff --git a/package.json b/package.json index c3341ff..f738055 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ }, "devDependencies": { "@eslint/js": "^9.17.0", + "@types/node": "^25.3.2", "eslint": "^9.17.0", "jsr": "^0.14.2", "prettier": "^3.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 488d30a..5e4d3e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: '@eslint/js': specifier: ^9.17.0 version: 9.39.2 + '@types/node': + specifier: ^25.3.2 + version: 25.3.2 eslint: specifier: ^9.17.0 version: 9.39.2 @@ -47,7 +50,7 @@ importers: version: 3.8.1 vitest: specifier: ^2.1.8 - version: 2.1.9 + version: 2.1.9(@types/node@25.3.2) packages: @@ -440,6 +443,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@25.3.2': + resolution: {integrity: sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==} + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -867,6 +873,9 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1196,6 +1205,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@25.3.2': + dependencies: + undici-types: 7.18.2 + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -1203,13 +1216,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.9(vite@5.4.21)': + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@25.3.2))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21 + vite: 5.4.21(@types/node@25.3.2) '@vitest/pretty-format@2.1.9': dependencies: @@ -1645,17 +1658,19 @@ snapshots: dependencies: prelude-ls: 1.2.1 + undici-types@7.18.2: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 - vite-node@2.1.9: + vite-node@2.1.9(@types/node@25.3.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.21 + vite: 5.4.21(@types/node@25.3.2) transitivePeerDependencies: - '@types/node' - less @@ -1667,18 +1682,19 @@ snapshots: - supports-color - terser - vite@5.4.21: + vite@5.4.21(@types/node@25.3.2): dependencies: esbuild: 0.21.5 postcss: 8.5.6 rollup: 4.57.1 optionalDependencies: + '@types/node': 25.3.2 fsevents: 2.3.3 - vitest@2.1.9: + vitest@2.1.9(@types/node@25.3.2): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21) + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@25.3.2)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -1694,9 +1710,11 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.1.1 tinyrainbow: 1.2.0 - vite: 5.4.21 - vite-node: 2.1.9 + vite: 5.4.21(@types/node@25.3.2) + vite-node: 2.1.9(@types/node@25.3.2) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.3.2 transitivePeerDependencies: - less - lightningcss diff --git a/src/domain/services/Semaphore.js b/src/domain/services/Semaphore.js index 266bda2..507ed14 100644 --- a/src/domain/services/Semaphore.js +++ b/src/domain/services/Semaphore.js @@ -4,6 +4,7 @@ export default class Semaphore { #max; #active = 0; + /** @type {Array<() => void>} */ #queue = []; /** @@ -35,8 +36,7 @@ export default class Semaphore { */ release() { if (this.#queue.length > 0) { - const next = this.#queue.shift(); - next(); + /** @type {() => void} */ (this.#queue.shift())(); } else { if (this.#active === 0) { throw new Error('Semaphore release called without an active permit'); diff --git a/src/domain/services/VaultService.js b/src/domain/services/VaultService.js index ae8989f..2650ad3 100644 --- a/src/domain/services/VaultService.js +++ b/src/domain/services/VaultService.js @@ -7,6 +7,28 @@ const VAULT_REF = 'refs/cas/vault'; const MAX_CAS_RETRIES = 3; const CAS_RETRY_BASE_MS = 50; +/** + * Vault encryption metadata stored in .vault.json. + * @typedef {Object} VaultEncryptionMeta + * @property {string} cipher - Cipher algorithm (e.g. 'aes-256-gcm'). + * @property {{ algorithm: string, salt: string, iterations?: number, cost?: number, blockSize?: number, parallelization?: number, keyLength: number }} kdf - KDF parameters. + */ + +/** + * Vault metadata stored in .vault.json. + * @typedef {Object} VaultMetadata + * @property {number} version - Metadata version (currently 1). + * @property {VaultEncryptionMeta} [encryption] - Encryption configuration. + */ + +/** + * Vault state read from refs/cas/vault. + * @typedef {Object} VaultState + * @property {Map} entries - Slug→treeOid map. + * @property {string|null} parentCommitOid - Parent commit OID. + * @property {VaultMetadata|null} metadata - Vault metadata. + */ + /** * Percent-encodes a vault slug for use as a git tree entry name. * Git tree entry names cannot contain '/'. @@ -59,9 +81,9 @@ export default class VaultService { /** * @param {Object} options - * @param {import('../../../src/ports/GitPersistencePort.js').default} options.persistence - * @param {import('../../../src/ports/GitRefPort.js').default} options.ref - * @param {import('../../../src/ports/CryptoPort.js').default} options.crypto + * @param {import('../../ports/GitPersistencePort.js').default} options.persistence + * @param {import('../../ports/GitRefPort.js').default} options.ref + * @param {import('../../ports/CryptoPort.js').default} options.crypto */ constructor({ persistence, ref, crypto }) { this.persistence = persistence; @@ -75,7 +97,8 @@ export default class VaultService { /** * Validates a single slug segment. - * @private + * @param {string} seg - Segment to validate. + * @param {string} slug - Full slug (for error context). */ static #validateSegment(seg, slug) { if (seg.length === 0) { @@ -118,7 +141,8 @@ export default class VaultService { /** * Validates encryption-specific metadata fields. - * @private + * @param {VaultEncryptionMeta} encryption - Encryption metadata. + * @param {VaultMetadata} metadata - Full metadata (for error context). */ static #validateEncryption(encryption, metadata) { const { cipher, kdf } = encryption; @@ -133,7 +157,7 @@ export default class VaultService { /** * Validates vault metadata object structure. - * @private + * @param {VaultMetadata} metadata - Metadata to validate. */ static #validateMetadata(metadata) { if (typeof metadata.version !== 'number' || metadata.version !== 1) { @@ -150,7 +174,8 @@ export default class VaultService { /** * Reads and validates vault metadata from a blob OID. - * @private + * @param {string} blobOid - Git blob OID of the .vault.json file. + * @returns {Promise} */ async #readMetadataBlob(blobOid) { try { @@ -161,7 +186,7 @@ export default class VaultService { } catch (err) { if (err instanceof CasError) { throw err; } throw new CasError( - `Failed to parse .vault.json: ${err.message}`, + `Failed to parse .vault.json: ${/** @type {Error} */ (err).message}`, 'VAULT_METADATA_INVALID', { originalError: err }, ); @@ -174,8 +199,8 @@ export default class VaultService { /** * Separates vault tree entries into slug→OID map and metadata blob OID. - * @private * @param {Array<{ mode: string, type: string, oid: string, name: string }>} treeEntries + * @returns {{ entries: Map, metadataBlobOid: string|null }} */ static #parseTreeEntries(treeEntries) { const entries = new Map(); @@ -192,7 +217,7 @@ export default class VaultService { /** * Reads the current vault state from refs/cas/vault. - * @returns {Promise<{ entries: Map, parentCommitOid: string|null, metadata: object|null }>} + * @returns {Promise} */ async readState() { let commitOid; @@ -216,7 +241,7 @@ export default class VaultService { * Writes a new vault commit and updates the ref atomically. * @param {Object} options * @param {Map} options.entries - Slug→treeOid map. - * @param {object} options.metadata - Vault metadata (.vault.json contents). + * @param {VaultMetadata} options.metadata - Vault metadata (.vault.json contents). * @param {string|null} options.parentCommitOid - Parent commit OID (null for first commit). * @param {string} options.message - Commit message. * @returns {Promise<{ commitOid: string }>} @@ -244,7 +269,8 @@ export default class VaultService { /** * Atomically updates the vault ref with CAS semantics. - * @private + * @param {string} newOid - New commit OID. + * @param {string|null} expectedOldOid - Expected current commit OID. */ async #casUpdateRef(newOid, expectedOldOid) { try { @@ -264,8 +290,7 @@ export default class VaultService { /** * Wraps a vault mutation with CAS retry logic. - * @private - * @param {function} mutationFn - Async function(state) → { entries, metadata, message } + * @param {(state: VaultState) => { entries: Map, metadata: VaultMetadata, message: string }|Promise<{ entries: Map, metadata: VaultMetadata, message: string }>} mutationFn - Mutation function (sync or async). * @returns {Promise<{ commitOid: string }>} */ async #retryMutation(mutationFn) { @@ -295,7 +320,9 @@ export default class VaultService { /** * Builds vault encryption metadata from KDF result. - * @private + * @param {Buffer} salt - KDF salt. + * @param {import('../../ports/CryptoPort.js').KdfParamSet} params - KDF parameters. + * @returns {VaultEncryptionMeta} */ static #buildEncryptionMeta(salt, params) { return { @@ -333,6 +360,7 @@ export default class VaultService { ); } + /** @type {VaultMetadata} */ const metadata = { version: 1 }; if (passphrase) { const { salt, params } = await this.crypto.deriveKey({ passphrase, ...kdfOptions }); @@ -394,6 +422,7 @@ export default class VaultService { * @returns {Promise<{ commitOid: string, removedTreeOid: string }>} */ async removeFromVault({ slug }) { + /** @type {string|undefined} */ let removedTreeOid; const result = await this.#retryMutation((state) => { @@ -413,7 +442,7 @@ export default class VaultService { }; }); - return { commitOid: result.commitOid, removedTreeOid }; + return { commitOid: result.commitOid, removedTreeOid: /** @type {string} */ (removedTreeOid) }; } /** @@ -431,12 +460,12 @@ export default class VaultService { { slug }, ); } - return entries.get(slug); + return /** @type {string} */ (entries.get(slug)); } /** * Returns the vault metadata, or null if no vault exists. - * @returns {Promise} + * @returns {Promise} */ async getVaultMetadata() { const { metadata } = await this.readState(); diff --git a/src/infrastructure/adapters/BunCryptoAdapter.js b/src/infrastructure/adapters/BunCryptoAdapter.js index c5b8e74..1d8b8ce 100644 --- a/src/infrastructure/adapters/BunCryptoAdapter.js +++ b/src/infrastructure/adapters/BunCryptoAdapter.js @@ -1,3 +1,4 @@ +// @ts-ignore -- 'bun' module only available in Bun runtime import { CryptoHasher } from 'bun'; import CryptoPort from '../../ports/CryptoPort.js'; import CasError from '../../domain/errors/CasError.js'; @@ -14,18 +15,31 @@ import { promisify } from 'node:util'; * AES-256-GCM (Bun's Node compat layer is heavily optimized for these APIs). */ export default class BunCryptoAdapter extends CryptoPort { - /** @override */ + /** + * @override + * @param {Buffer|Uint8Array} buf - Data to hash. + * @returns {Promise} 64-char hex digest. + */ async sha256(buf) { return new CryptoHasher('sha256').update(buf).digest('hex'); } - /** @override */ + /** + * @override + * @param {number} n - Number of random bytes. + * @returns {Buffer} + */ randomBytes(n) { const uint8 = globalThis.crypto.getRandomValues(new Uint8Array(n)); return Buffer.from(uint8.buffer, uint8.byteOffset, uint8.byteLength); } - /** @override */ + /** + * @override + * @param {Buffer|Uint8Array} buffer - Plaintext to encrypt. + * @param {Buffer|Uint8Array} key - 32-byte encryption key. + * @returns {Promise<{ buf: Buffer, meta: import('../../ports/CryptoPort.js').EncryptionMeta }>} + */ async encryptBuffer(buffer, key) { this._validateKey(key); const nonce = this.randomBytes(12); @@ -38,7 +52,13 @@ export default class BunCryptoAdapter extends CryptoPort { }; } - /** @override */ + /** + * @override + * @param {Buffer|Uint8Array} buffer - Ciphertext to decrypt. + * @param {Buffer|Uint8Array} key - 32-byte encryption key. + * @param {import('../../ports/CryptoPort.js').EncryptionMeta} meta - Encryption metadata. + * @returns {Promise} + */ async decryptBuffer(buffer, key, meta) { this._validateKey(key); const nonce = Buffer.from(meta.nonce, 'base64'); @@ -48,13 +68,18 @@ export default class BunCryptoAdapter extends CryptoPort { return Buffer.concat([decipher.update(buffer), decipher.final()]); } - /** @override */ + /** + * @override + * @param {Buffer|Uint8Array} key - 32-byte encryption key. + * @returns {{ encrypt: (source: AsyncIterable) => AsyncIterable, finalize: () => import('../../ports/CryptoPort.js').EncryptionMeta }} + */ createEncryptionStream(key) { this._validateKey(key); const nonce = this.randomBytes(12); const cipher = createCipheriv('aes-256-gcm', key, nonce); let streamFinalized = false; + /** @param {AsyncIterable} source */ const encrypt = async function* (source) { for await (const chunk of source) { const encrypted = cipher.update(chunk); @@ -83,11 +108,18 @@ export default class BunCryptoAdapter extends CryptoPort { return { encrypt, finalize }; } - /** @override */ + /** + * @override + * @param {string} passphrase - The passphrase. + * @param {Buffer|Uint8Array} saltBuf - Salt bytes. + * @param {import('../../ports/CryptoPort.js').DeriveKeyParams} params - KDF parameters. + * @returns {Promise} + */ async _doDeriveKey(passphrase, saltBuf, { algorithm, iterations, cost, blockSize, parallelization, keyLength }) { if (algorithm === 'pbkdf2') { return promisify(pbkdf2)(passphrase, saltBuf, iterations, keyLength, 'sha512'); } + // @ts-ignore -- promisify(scrypt) accepts options as 4th arg at runtime return promisify(scrypt)(passphrase, saltBuf, keyLength, { N: cost, r: blockSize, diff --git a/src/infrastructure/adapters/EventEmitterObserver.js b/src/infrastructure/adapters/EventEmitterObserver.js index 2c227bb..f99a7ed 100644 --- a/src/infrastructure/adapters/EventEmitterObserver.js +++ b/src/infrastructure/adapters/EventEmitterObserver.js @@ -16,8 +16,8 @@ export default class EventEmitterObserver { * Error metrics are only emitted when listeners are attached (matching * the previous CasService behavior that guarded `this.emit('error', ...)`). * - * @param {string} channel - * @param {Object} data - Must include `action` to form the event name. + * @param {string} channel - Metric channel. + * @param {Record & { action?: string }} data - Must include `action` to form the event name. */ metric(channel, data) { if (channel === 'error') { @@ -31,16 +31,25 @@ export default class EventEmitterObserver { this.#emitter.emit(eventName, payload); } + /** + * @param {'debug'|'info'|'warn'|'error'} _level - Log level. + * @param {string} _msg - Log message. + * @param {Record} [_meta] - Optional metadata. + */ log(_level, _msg, _meta) {} + /** + * @param {string} _name - Span name. + * @returns {{ end(meta?: Record): void }} + */ span(_name) { return { end() {} }; } /** * Subscribe to an event. - * @param {string} event - * @param {Function} listener + * @param {string} event - Event name. + * @param {(...args: unknown[]) => void} listener - Event listener. * @returns {this} */ on(event, listener) { @@ -50,8 +59,8 @@ export default class EventEmitterObserver { /** * Remove a listener. - * @param {string} event - * @param {Function} listener + * @param {string} event - Event name. + * @param {(...args: unknown[]) => void} listener - Event listener. * @returns {this} */ removeListener(event, listener) { @@ -61,7 +70,7 @@ export default class EventEmitterObserver { /** * Return the number of listeners for an event. - * @param {string} event + * @param {string} event - Event name. * @returns {number} */ listenerCount(event) { diff --git a/src/infrastructure/adapters/GitPersistenceAdapter.js b/src/infrastructure/adapters/GitPersistenceAdapter.js index 9e0805e..797be53 100644 --- a/src/infrastructure/adapters/GitPersistenceAdapter.js +++ b/src/infrastructure/adapters/GitPersistenceAdapter.js @@ -30,7 +30,11 @@ export default class GitPersistenceAdapter extends GitPersistencePort { this.policy = policy ?? DEFAULT_POLICY; } - /** @override */ + /** + * @override + * @param {Buffer|string} content - Data to store. + * @returns {Promise} The Git OID of the stored blob. + */ async writeBlob(content) { return this.policy.execute(() => this.plumbing.execute({ @@ -40,7 +44,11 @@ export default class GitPersistenceAdapter extends GitPersistencePort { ); } - /** @override */ + /** + * @override + * @param {string[]} entries - Lines in `git mktree` format. + * @returns {Promise} The Git OID of the created tree. + */ async writeTree(entries) { return this.policy.execute(() => this.plumbing.execute({ @@ -50,7 +58,11 @@ export default class GitPersistenceAdapter extends GitPersistencePort { ); } - /** @override */ + /** + * @override + * @param {string} oid - Git object ID. + * @returns {Promise} The blob content. + */ async readBlob(oid) { return this.policy.execute(async () => { const stream = await this.plumbing.executeStream({ @@ -62,7 +74,11 @@ export default class GitPersistenceAdapter extends GitPersistencePort { }); } - /** @override */ + /** + * @override + * @param {string} treeOid - Git tree OID. + * @returns {Promise>} + */ async readTree(treeOid) { return this.policy.execute(async () => { const output = await this.plumbing.execute({ @@ -73,7 +89,7 @@ export default class GitPersistenceAdapter extends GitPersistencePort { return []; } - return output.split('\0').filter(Boolean).map((entry) => { + return output.split('\0').filter(Boolean).map((/** @type {string} */ entry) => { // Format: \t const tabIndex = entry.indexOf('\t'); if (tabIndex === -1) { diff --git a/src/infrastructure/adapters/GitRefAdapter.js b/src/infrastructure/adapters/GitRefAdapter.js index 1c70517..20759a6 100644 --- a/src/infrastructure/adapters/GitRefAdapter.js +++ b/src/infrastructure/adapters/GitRefAdapter.js @@ -29,21 +29,36 @@ export default class GitRefAdapter extends GitRefPort { this.policy = policy ?? DEFAULT_POLICY; } - /** @override */ + /** + * @override + * @param {string} ref - Git ref to resolve. + * @returns {Promise} The commit OID. + */ async resolveRef(ref) { return this.policy.execute(() => this.plumbing.execute({ args: ['rev-parse', ref] }), ); } - /** @override */ + /** + * @override + * @param {string} commitOid - Git commit OID. + * @returns {Promise} The tree OID. + */ async resolveTree(commitOid) { return this.policy.execute(() => this.plumbing.execute({ args: ['rev-parse', `${commitOid}^{tree}`] }), ); } - /** @override */ + /** + * @override + * @param {Object} options + * @param {string} options.treeOid - Tree OID for the commit. + * @param {string|null} [options.parentOid] - Parent commit OID. + * @param {string} options.message - Commit message. + * @returns {Promise} The new commit OID. + */ async createCommit({ treeOid, parentOid, message }) { const args = ['commit-tree', treeOid, '-m', message]; if (parentOid) { @@ -54,13 +69,20 @@ export default class GitRefAdapter extends GitRefPort { ); } - /** @override */ + /** + * @override + * @param {Object} options + * @param {string} options.ref - Git ref to update. + * @param {string} options.newOid - New OID to set. + * @param {string|null} [options.expectedOldOid] - Expected current OID for CAS. + * @returns {Promise} + */ async updateRef({ ref, newOid, expectedOldOid }) { const args = ['update-ref', ref, newOid]; if (expectedOldOid) { args.push(expectedOldOid); } - return this.policy.execute(() => + await this.policy.execute(() => this.plumbing.execute({ args }), ); } diff --git a/src/infrastructure/adapters/NodeCryptoAdapter.js b/src/infrastructure/adapters/NodeCryptoAdapter.js index 255f04f..ea6c963 100644 --- a/src/infrastructure/adapters/NodeCryptoAdapter.js +++ b/src/infrastructure/adapters/NodeCryptoAdapter.js @@ -6,17 +6,30 @@ import CryptoPort from '../../ports/CryptoPort.js'; * Node.js implementation of CryptoPort using node:crypto. */ export default class NodeCryptoAdapter extends CryptoPort { - /** @override */ + /** + * @override + * @param {Buffer|Uint8Array} buf - Data to hash. + * @returns {string} 64-char hex digest. + */ sha256(buf) { return createHash('sha256').update(buf).digest('hex'); } - /** @override */ + /** + * @override + * @param {number} n - Number of random bytes. + * @returns {Buffer} + */ randomBytes(n) { return randomBytes(n); } - /** @override */ + /** + * @override + * @param {Buffer|Uint8Array} buffer - Plaintext to encrypt. + * @param {Buffer|Uint8Array} key - 32-byte encryption key. + * @returns {{ buf: Buffer, meta: import('../../ports/CryptoPort.js').EncryptionMeta }} + */ encryptBuffer(buffer, key) { this._validateKey(key); const nonce = randomBytes(12); @@ -29,7 +42,13 @@ export default class NodeCryptoAdapter extends CryptoPort { }; } - /** @override */ + /** + * @override + * @param {Buffer|Uint8Array} buffer - Ciphertext to decrypt. + * @param {Buffer|Uint8Array} key - 32-byte encryption key. + * @param {import('../../ports/CryptoPort.js').EncryptionMeta} meta - Encryption metadata. + * @returns {Buffer} + */ decryptBuffer(buffer, key, meta) { const nonce = Buffer.from(meta.nonce, 'base64'); const tag = Buffer.from(meta.tag, 'base64'); @@ -38,12 +57,17 @@ export default class NodeCryptoAdapter extends CryptoPort { return Buffer.concat([decipher.update(buffer), decipher.final()]); } - /** @override */ + /** + * @override + * @param {Buffer|Uint8Array} key - 32-byte encryption key. + * @returns {{ encrypt: (source: AsyncIterable) => AsyncIterable, finalize: () => import('../../ports/CryptoPort.js').EncryptionMeta }} + */ createEncryptionStream(key) { this._validateKey(key); const nonce = randomBytes(12); const cipher = createCipheriv('aes-256-gcm', key, nonce); + /** @param {AsyncIterable} source */ const encrypt = async function* (source) { for await (const chunk of source) { const encrypted = cipher.update(chunk); @@ -65,11 +89,18 @@ export default class NodeCryptoAdapter extends CryptoPort { return { encrypt, finalize }; } - /** @override */ + /** + * @override + * @param {string} passphrase - The passphrase. + * @param {Buffer|Uint8Array} saltBuf - Salt bytes. + * @param {import('../../ports/CryptoPort.js').DeriveKeyParams} params - KDF parameters. + * @returns {Promise} + */ async _doDeriveKey(passphrase, saltBuf, { algorithm, iterations, cost, blockSize, parallelization, keyLength }) { if (algorithm === 'pbkdf2') { return await promisify(pbkdf2)(passphrase, saltBuf, iterations, keyLength, 'sha512'); } + // @ts-ignore -- promisify(scrypt) accepts options as 4th arg at runtime return await promisify(scrypt)(passphrase, saltBuf, keyLength, { N: cost, r: blockSize, diff --git a/src/infrastructure/adapters/SilentObserver.js b/src/infrastructure/adapters/SilentObserver.js index e05e415..9e93d1f 100644 --- a/src/infrastructure/adapters/SilentObserver.js +++ b/src/infrastructure/adapters/SilentObserver.js @@ -3,8 +3,23 @@ * Used as the default when no observability is configured. */ export default class SilentObserver { + /** + * @param {string} _channel - Metric channel. + * @param {Record} _data - Metric payload. + */ metric(_channel, _data) {} + + /** + * @param {'debug'|'info'|'warn'|'error'} _level - Log level. + * @param {string} _msg - Log message. + * @param {Record} [_meta] - Optional metadata. + */ log(_level, _msg, _meta) {} + + /** + * @param {string} _name - Span name. + * @returns {{ end(meta?: Record): void }} + */ span(_name) { return { end() {} }; } diff --git a/src/infrastructure/adapters/StatsCollector.js b/src/infrastructure/adapters/StatsCollector.js index a19a58a..4e6a90d 100644 --- a/src/infrastructure/adapters/StatsCollector.js +++ b/src/infrastructure/adapters/StatsCollector.js @@ -5,15 +5,20 @@ export default class StatsCollector { #chunksProcessed = 0; #bytesTotal = 0; #errors = 0; + /** @type {number|null} */ #startTime = null; + /** + * @param {string} channel - Metric channel. + * @param {Record} data - Metric payload. + */ metric(channel, data) { if (!this.#startTime) { this.#startTime = Date.now(); } if (channel === 'chunk') { this.#chunksProcessed++; - const size = Number.isFinite(data?.size) ? data.size : 0; + const size = Number.isFinite(data?.size) ? /** @type {number} */ (data.size) : 0; this.#bytesTotal += size; } if (channel === 'error') { @@ -21,8 +26,17 @@ export default class StatsCollector { } } + /** + * @param {'debug'|'info'|'warn'|'error'} _level - Log level. + * @param {string} _msg - Log message. + * @param {Record} [_meta] - Optional metadata. + */ log(_level, _msg, _meta) {} + /** + * @param {string} _name - Span name. + * @returns {{ end(meta?: Record): void }} + */ span(_name) { return { end() {} }; } diff --git a/src/infrastructure/adapters/WebCryptoAdapter.js b/src/infrastructure/adapters/WebCryptoAdapter.js index a6ffd51..5a70733 100644 --- a/src/infrastructure/adapters/WebCryptoAdapter.js +++ b/src/infrastructure/adapters/WebCryptoAdapter.js @@ -9,15 +9,24 @@ import CasError from '../../domain/errors/CasError.js'; * AES-GCM is a one-shot API (the GCM tag is computed over the entire plaintext). */ export default class WebCryptoAdapter extends CryptoPort { - /** @override */ + /** + * @override + * @param {Buffer|Uint8Array} buf - Data to hash. + * @returns {Promise} 64-char hex digest. + */ async sha256(buf) { + // @ts-ignore -- Buffer satisfies BufferSource at runtime; TS strictness mismatch const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', buf); return Array.from(new Uint8Array(hashBuffer)) .map(b => b.toString(16).padStart(2, '0')) .join(''); } - /** @override */ + /** + * @override + * @param {number} n - Number of random bytes. + * @returns {Buffer|Uint8Array} + */ randomBytes(n) { const uint8 = globalThis.crypto.getRandomValues(new Uint8Array(n)); if (globalThis.Buffer) { @@ -26,7 +35,12 @@ export default class WebCryptoAdapter extends CryptoPort { return uint8; } - /** @override */ + /** + * @override + * @param {Buffer|Uint8Array} buffer - Plaintext to encrypt. + * @param {Buffer|Uint8Array} key - 32-byte encryption key. + * @returns {Promise<{ buf: Buffer, meta: import('../../ports/CryptoPort.js').EncryptionMeta }>} + */ async encryptBuffer(buffer, key) { this._validateKey(key); const nonce = this.randomBytes(12); @@ -34,7 +48,8 @@ export default class WebCryptoAdapter extends CryptoPort { // AES-GCM in Web Crypto includes the tag at the end of the ciphertext const encrypted = await globalThis.crypto.subtle.encrypt( - { name: 'AES-GCM', iv: nonce }, + // @ts-ignore -- Uint8Array satisfies BufferSource at runtime + { name: 'AES-GCM', iv: /** @type {Uint8Array} */ (nonce) }, cryptoKey, buffer ); @@ -50,7 +65,13 @@ export default class WebCryptoAdapter extends CryptoPort { }; } - /** @override */ + /** + * @override + * @param {Buffer|Uint8Array} buffer - Ciphertext to decrypt. + * @param {Buffer|Uint8Array} key - 32-byte encryption key. + * @param {import('../../ports/CryptoPort.js').EncryptionMeta} meta - Encryption metadata. + * @returns {Promise} + */ async decryptBuffer(buffer, key, meta) { const nonce = this.#fromBase64(meta.nonce); const tag = this.#fromBase64(meta.tag); @@ -63,7 +84,8 @@ export default class WebCryptoAdapter extends CryptoPort { try { const decrypted = await globalThis.crypto.subtle.decrypt( - { name: 'AES-GCM', iv: nonce }, + // @ts-ignore -- Uint8Array satisfies BufferSource at runtime + { name: 'AES-GCM', iv: /** @type {Uint8Array} */ (nonce) }, cryptoKey, combined ); @@ -73,20 +95,24 @@ export default class WebCryptoAdapter extends CryptoPort { } } - /** @override */ + /** + * @override + * @param {Buffer|Uint8Array} key - 32-byte encryption key. + * @returns {{ encrypt: (source: AsyncIterable) => AsyncIterable, finalize: () => import('../../ports/CryptoPort.js').EncryptionMeta }} + */ createEncryptionStream(key) { this._validateKey(key); const nonce = this.randomBytes(12); const cryptoKeyPromise = this.#importKey(key); - // Web Crypto doesn't have a native streaming AES-GCM API like Node - // We have to buffer for the one-shot call because GCM tag is computed over the whole thing. - // NOTE: This limits the "stream" to memory capacity, matching the project's - // current CasService.restore limitation. + // Web Crypto buffers all data for the one-shot AES-GCM call (GCM tag spans the whole plaintext). + /** @type {Buffer[]} */ const chunks = []; + /** @type {Uint8Array|null} */ let finalTag = null; let streamConsumed = false; + /** @param {AsyncIterable} source */ const encrypt = async function* (source) { for await (const chunk of source) { chunks.push(chunk); @@ -95,7 +121,8 @@ export default class WebCryptoAdapter extends CryptoPort { const buffer = Buffer.concat(chunks); const cryptoKey = await cryptoKeyPromise; const encrypted = await globalThis.crypto.subtle.encrypt( - { name: 'AES-GCM', iv: nonce }, + // @ts-ignore -- Uint8Array satisfies BufferSource at runtime + { name: 'AES-GCM', iv: /** @type {Uint8Array} */ (nonce) }, cryptoKey, buffer ); @@ -116,13 +143,19 @@ export default class WebCryptoAdapter extends CryptoPort { 'STREAM_NOT_CONSUMED', ); } - return this._buildMeta(this.#toBase64(nonce), this.#toBase64(finalTag)); + return this._buildMeta(this.#toBase64(nonce), this.#toBase64(/** @type {Uint8Array} */ (finalTag))); }; return { encrypt, finalize }; } - /** @override */ + /** + * @override + * @param {string} passphrase - The passphrase. + * @param {Buffer|Uint8Array} saltBuf - Salt bytes. + * @param {import('../../ports/CryptoPort.js').DeriveKeyParams} params - KDF parameters. + * @returns {Promise} + */ async _doDeriveKey(passphrase, saltBuf, { algorithm, iterations, cost, blockSize, parallelization, keyLength }) { if (algorithm === 'pbkdf2') { return this.#derivePbkdf2(passphrase, saltBuf, { iterations, keyLength }); @@ -130,18 +163,33 @@ export default class WebCryptoAdapter extends CryptoPort { return this.#deriveScrypt(passphrase, saltBuf, { cost, blockSize, parallelization, keyLength }); } + /** + * Derives a key using PBKDF2 via Web Crypto. + * @param {string} passphrase - The passphrase. + * @param {Buffer|Uint8Array} saltBuf - Salt bytes. + * @param {{ iterations: number, keyLength: number }} params - PBKDF2 parameters. + * @returns {Promise} + */ async #derivePbkdf2(passphrase, saltBuf, params) { const enc = new globalThis.TextEncoder(); const baseKey = await globalThis.crypto.subtle.importKey( 'raw', enc.encode(passphrase), 'PBKDF2', false, ['deriveBits'], ); const bits = await globalThis.crypto.subtle.deriveBits( - { name: 'PBKDF2', salt: saltBuf, iterations: params.iterations, hash: 'SHA-512' }, + // @ts-ignore -- Uint8Array satisfies BufferSource at runtime + { name: 'PBKDF2', salt: /** @type {Uint8Array} */ (saltBuf), iterations: params.iterations, hash: 'SHA-512' }, baseKey, params.keyLength * 8, ); return Buffer.from(bits); } + /** + * Derives a key using scrypt via Node's crypto module (fallback). + * @param {string} passphrase - The passphrase. + * @param {Buffer|Uint8Array} saltBuf - Salt bytes. + * @param {{ cost: number, blockSize: number, parallelization: number, keyLength: number }} params - scrypt parameters. + * @returns {Promise} + */ async #deriveScrypt(passphrase, saltBuf, params) { let scryptCb; let promisifyFn; @@ -151,6 +199,7 @@ export default class WebCryptoAdapter extends CryptoPort { } catch { throw new Error('scrypt KDF requires a Node.js-compatible runtime (node:crypto unavailable)'); } + // @ts-ignore -- promisify(scrypt) accepts options as 4th arg at runtime return promisifyFn(scryptCb)(passphrase, saltBuf, params.keyLength, { N: params.cost, r: params.blockSize, p: params.parallelization, }); @@ -164,7 +213,8 @@ export default class WebCryptoAdapter extends CryptoPort { async #importKey(rawKey) { return globalThis.crypto.subtle.importKey( 'raw', - rawKey, + // @ts-ignore -- Buffer/Uint8Array satisfies BufferSource at runtime + /** @type {Uint8Array} */ (rawKey), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'] @@ -173,7 +223,7 @@ export default class WebCryptoAdapter extends CryptoPort { /** * Encodes binary data to base64, using Buffer when available. - * @param {Uint8Array} buf + * @param {Buffer|Uint8Array} buf - Binary data to encode. * @returns {string} */ #toBase64(buf) { @@ -185,7 +235,7 @@ export default class WebCryptoAdapter extends CryptoPort { /** * Decodes a base64 string to binary, using Buffer when available. - * @param {string} str + * @param {string} str - Base64-encoded string. * @returns {Buffer|Uint8Array} */ #fromBase64(str) { diff --git a/src/infrastructure/chunkers/FixedChunker.js b/src/infrastructure/chunkers/FixedChunker.js index 0f904fa..1477e18 100644 --- a/src/infrastructure/chunkers/FixedChunker.js +++ b/src/infrastructure/chunkers/FixedChunker.js @@ -30,7 +30,11 @@ export default class FixedChunker extends ChunkingPort { return { chunkSize: this.#chunkSize }; } - /** @override */ + /** + * @override + * @param {AsyncIterable} source - The input byte stream. + * @yields {Buffer} + */ async *chunk(source) { let buffer = Buffer.alloc(0); diff --git a/src/infrastructure/codecs/CborCodec.js b/src/infrastructure/codecs/CborCodec.js index ec0d1b4..b89984e 100644 --- a/src/infrastructure/codecs/CborCodec.js +++ b/src/infrastructure/codecs/CborCodec.js @@ -5,14 +5,22 @@ import { encode, decode } from 'cbor-x'; * {@link CodecPort} implementation that serializes manifests as CBOR (binary). */ export default class CborCodec extends CodecPort { - /** @override */ + /** + * @override + * @param {Record} data - Data to encode. + * @returns {Buffer} + */ encode(data) { return encode(data); } - /** @override */ + /** + * @override + * @param {Buffer|string} buffer - CBOR-encoded data. + * @returns {Record} + */ decode(buffer) { - return decode(buffer); + return decode(/** @type {Buffer} */ (buffer)); } /** @override */ diff --git a/src/infrastructure/codecs/JsonCodec.js b/src/infrastructure/codecs/JsonCodec.js index 8f219f5..f45db3b 100644 --- a/src/infrastructure/codecs/JsonCodec.js +++ b/src/infrastructure/codecs/JsonCodec.js @@ -4,14 +4,22 @@ import CodecPort from '../../ports/CodecPort.js'; * {@link CodecPort} implementation that serializes manifests as pretty-printed JSON. */ export default class JsonCodec extends CodecPort { - /** @override */ + /** + * @override + * @param {Record} data - Data to encode. + * @returns {string} + */ encode(data) { // Determine if we need to handle Buffers specially for JSON // For now, we assume data is JSON-safe or uses toJSON() methods return JSON.stringify(data, null, 2); } - /** @override */ + /** + * @override + * @param {Buffer|string} buffer - JSON-encoded data. + * @returns {Record} + */ decode(buffer) { return JSON.parse(buffer.toString('utf8')); } diff --git a/src/ports/CodecPort.js b/src/ports/CodecPort.js index c7d1235..46c159c 100644 --- a/src/ports/CodecPort.js +++ b/src/ports/CodecPort.js @@ -5,7 +5,7 @@ export default class CodecPort { /** * Encodes data to a Buffer or string. - * @param {Object} data + * @param {Record} _data - Data to encode. * @returns {Buffer|string} */ encode(_data) { @@ -14,8 +14,8 @@ export default class CodecPort { /** * Decodes data from a Buffer or string. - * @param {Buffer|string} buffer - * @returns {Object} + * @param {Buffer|string} _buffer - Encoded data to decode. + * @returns {Record} */ decode(_buffer) { throw new Error('Not implemented'); diff --git a/src/ports/CryptoPort.js b/src/ports/CryptoPort.js index fcddef7..7953029 100644 --- a/src/ports/CryptoPort.js +++ b/src/ports/CryptoPort.js @@ -1,5 +1,37 @@ import CasError from '../domain/errors/CasError.js'; +/** + * Encryption metadata returned by AES-256-GCM operations. + * @typedef {Object} EncryptionMeta + * @property {string} algorithm - Cipher algorithm identifier (e.g. `'aes-256-gcm'`). + * @property {string} nonce - Base64-encoded 12-byte nonce. + * @property {string} tag - Base64-encoded 16-byte GCM authentication tag. + * @property {boolean} encrypted - Whether the data is encrypted. + */ + +/** + * KDF parameter set stored alongside derived keys. + * @typedef {Object} KdfParamSet + * @property {'pbkdf2'|'scrypt'} algorithm - KDF algorithm. + * @property {string} salt - Base64-encoded salt. + * @property {number} [iterations] - PBKDF2 iterations (present when algorithm is pbkdf2). + * @property {number} [cost] - scrypt cost N (present when algorithm is scrypt). + * @property {number} [blockSize] - scrypt block size r. + * @property {number} [parallelization] - scrypt parallelization p. + * @property {number} keyLength - Derived key length in bytes. + */ + +/** + * Normalized KDF options passed to `_doDeriveKey`. + * @typedef {Object} DeriveKeyParams + * @property {string} algorithm - KDF algorithm. + * @property {number} iterations - PBKDF2 iteration count. + * @property {number} cost - scrypt cost (N). + * @property {number} blockSize - scrypt block size (r). + * @property {number} parallelization - scrypt parallelization (p). + * @property {number} keyLength - Derived key length in bytes. + */ + /** * Abstract port for cryptographic operations (hashing, random bytes, AES-256-GCM). * @abstract @@ -7,8 +39,8 @@ import CasError from '../domain/errors/CasError.js'; export default class CryptoPort { /** * Returns the SHA-256 hex digest of a buffer. - * @param {Buffer} buf - * @returns {string} 64-char hex digest + * @param {Buffer|Uint8Array} _buf - Data to hash. + * @returns {string|Promise} 64-char hex digest. */ sha256(_buf) { throw new Error('Not implemented'); @@ -16,8 +48,8 @@ export default class CryptoPort { /** * Returns a Buffer of n cryptographically random bytes. - * @param {number} n - * @returns {Buffer} + * @param {number} _n - Number of random bytes. + * @returns {Buffer|Uint8Array} */ randomBytes(_n) { throw new Error('Not implemented'); @@ -25,9 +57,9 @@ export default class CryptoPort { /** * Encrypts a buffer using AES-256-GCM. - * @param {Buffer} buffer - * @param {Buffer} key - 32-byte encryption key - * @returns {{ buf: Buffer, meta: { algorithm: string, nonce: string, tag: string, encrypted: boolean } }} + * @param {Buffer|Uint8Array} _buffer - Plaintext to encrypt. + * @param {Buffer|Uint8Array} _key - 32-byte encryption key. + * @returns {{ buf: Buffer, meta: EncryptionMeta }|Promise<{ buf: Buffer, meta: EncryptionMeta }>} */ encryptBuffer(_buffer, _key) { throw new Error('Not implemented'); @@ -35,11 +67,11 @@ export default class CryptoPort { /** * Decrypts a buffer using AES-256-GCM. - * @param {Buffer} buffer - * @param {Buffer} key - 32-byte encryption key - * @param {{ algorithm: string, nonce: string, tag: string, encrypted: boolean }} meta - * @returns {Buffer} - * @throws on authentication failure + * @param {Buffer|Uint8Array} _buffer - Ciphertext to decrypt. + * @param {Buffer|Uint8Array} _key - 32-byte encryption key. + * @param {EncryptionMeta} _meta - Encryption metadata from the encrypt operation. + * @returns {Buffer|Promise} + * @throws on authentication failure. */ decryptBuffer(_buffer, _key, _meta) { throw new Error('Not implemented'); @@ -47,8 +79,8 @@ export default class CryptoPort { /** * Creates a streaming encryption context. - * @param {Buffer} key - 32-byte encryption key - * @returns {{ encrypt: (source: AsyncIterable) => AsyncIterable, finalize: () => { algorithm: string, nonce: string, tag: string, encrypted: boolean } }} + * @param {Buffer|Uint8Array} _key - 32-byte encryption key. + * @returns {{ encrypt: (source: AsyncIterable) => AsyncIterable, finalize: () => EncryptionMeta }} */ createEncryptionStream(_key) { throw new Error('Not implemented'); @@ -69,7 +101,7 @@ export default class CryptoPort { * @param {number} [options.blockSize=8] - scrypt block size (r). * @param {number} [options.parallelization=1] - scrypt parallelization (p). * @param {number} [options.keyLength=32] - Derived key length in bytes. - * @returns {Promise<{ key: Buffer, salt: Buffer, params: { algorithm: string, salt: string, iterations?: number, cost?: number, blockSize?: number, parallelization?: number, keyLength: number } }>} + * @returns {Promise<{ key: Buffer, salt: Buffer, params: KdfParamSet }>} */ async deriveKey({ passphrase, @@ -83,6 +115,7 @@ export default class CryptoPort { }) { const saltBuf = salt || this.randomBytes(32); + /** @type {KdfParamSet} */ const params = { algorithm, salt: Buffer.from(saltBuf).toString('base64'), @@ -114,9 +147,9 @@ export default class CryptoPort { /** * Adapter-specific key derivation. Override in subclasses. * @abstract - * @param {string} passphrase - * @param {Buffer|Uint8Array} saltBuf - * @param {Object} params - Normalized KDF parameters. + * @param {string} _passphrase - The passphrase. + * @param {Buffer|Uint8Array} _saltBuf - Salt bytes. + * @param {DeriveKeyParams} _params - Normalized KDF parameters. * @returns {Promise} Derived key bytes. */ async _doDeriveKey(_passphrase, _saltBuf, _params) { @@ -125,9 +158,9 @@ export default class CryptoPort { /** * Validates that a key is a 32-byte Buffer or Uint8Array. - * @param {Buffer|Uint8Array} key - * @throws {CasError} INVALID_KEY_TYPE if key is not a Buffer or Uint8Array - * @throws {CasError} INVALID_KEY_LENGTH if key is not 32 bytes + * @param {Buffer|Uint8Array} key - Key to validate. + * @throws {CasError} INVALID_KEY_TYPE if key is not a Buffer or Uint8Array. + * @throws {CasError} INVALID_KEY_LENGTH if key is not 32 bytes. */ _validateKey(key) { if (!globalThis.Buffer?.isBuffer(key) && !(key instanceof Uint8Array)) { @@ -149,7 +182,7 @@ export default class CryptoPort { * Builds the encryption metadata object from base64-encoded nonce and tag. * @param {string} nonce64 - Base64-encoded 12-byte AES-GCM nonce. * @param {string} tag64 - Base64-encoded 16-byte GCM authentication tag. - * @returns {{ algorithm: string, nonce: string, tag: string, encrypted: boolean }} + * @returns {EncryptionMeta} */ _buildMeta(nonce64, tag64) { return { diff --git a/src/ports/GitPersistencePort.js b/src/ports/GitPersistencePort.js index 59352d2..d28526e 100644 --- a/src/ports/GitPersistencePort.js +++ b/src/ports/GitPersistencePort.js @@ -5,7 +5,7 @@ export default class GitPersistencePort { /** * Writes content as a Git blob object. - * @param {Buffer|string} content - Data to store. + * @param {Buffer|string} _content - Data to store. * @returns {Promise} The Git OID of the stored blob. */ async writeBlob(_content) { @@ -14,7 +14,7 @@ export default class GitPersistencePort { /** * Creates a Git tree object from formatted entries. - * @param {string[]} entries - Lines in `git mktree` format. + * @param {string[]} _entries - Lines in `git mktree` format. * @returns {Promise} The Git OID of the created tree. */ async writeTree(_entries) { @@ -23,7 +23,7 @@ export default class GitPersistencePort { /** * Reads a Git blob by its OID. - * @param {string} oid - Git object ID. + * @param {string} _oid - Git object ID. * @returns {Promise} The blob content. */ async readBlob(_oid) { @@ -32,7 +32,7 @@ export default class GitPersistencePort { /** * Reads and parses a Git tree object. - * @param {string} treeOid - Git tree OID. + * @param {string} _treeOid - Git tree OID. * @returns {Promise>} Parsed tree entries. */ async readTree(_treeOid) { diff --git a/src/ports/GitRefPort.js b/src/ports/GitRefPort.js index 5058b2c..82cf09a 100644 --- a/src/ports/GitRefPort.js +++ b/src/ports/GitRefPort.js @@ -5,7 +5,7 @@ export default class GitRefPort { /** * Resolves a Git ref to its commit OID. - * @param {string} ref - Git ref (e.g. 'refs/cas/vault'). + * @param {string} _ref - Git ref (e.g. 'refs/cas/vault'). * @returns {Promise} The commit OID. * @throws If the ref does not exist. */ @@ -15,7 +15,7 @@ export default class GitRefPort { /** * Resolves the tree OID from a commit OID. - * @param {string} commitOid - Git commit OID. + * @param {string} _commitOid - Git commit OID. * @returns {Promise} The tree OID. */ async resolveTree(_commitOid) { @@ -24,10 +24,10 @@ export default class GitRefPort { /** * Creates a Git commit object. - * @param {Object} options - * @param {string} options.treeOid - Tree OID for the commit. - * @param {string|null} [options.parentOid] - Parent commit OID (null for root commit). - * @param {string} options.message - Commit message. + * @param {Object} _options + * @param {string} _options.treeOid - Tree OID for the commit. + * @param {string|null} [_options.parentOid] - Parent commit OID (null for root commit). + * @param {string} _options.message - Commit message. * @returns {Promise} The new commit OID. */ async createCommit(_options) { @@ -36,10 +36,10 @@ export default class GitRefPort { /** * Atomically updates a Git ref with optional CAS (compare-and-swap) semantics. - * @param {Object} options - * @param {string} options.ref - Git ref to update. - * @param {string} options.newOid - New OID to set. - * @param {string|null} [options.expectedOldOid] - Expected current OID for CAS. If provided and mismatched, throws. + * @param {Object} _options + * @param {string} _options.ref - Git ref to update. + * @param {string} _options.newOid - New OID to set. + * @param {string|null} [_options.expectedOldOid] - Expected current OID for CAS. * @returns {Promise} */ async updateRef(_options) { diff --git a/src/types/ambient.d.ts b/src/types/ambient.d.ts new file mode 100644 index 0000000..7970e51 --- /dev/null +++ b/src/types/ambient.d.ts @@ -0,0 +1,28 @@ +/** + * Ambient module declarations for dependencies that lack type definitions. + */ + +declare module '@git-stunts/plumbing' { + interface ExecuteOptions { + args: string[]; + input?: string | Buffer; + } + + interface StreamResult { + collect(options?: { asString?: boolean }): Promise; + } + + export default class GitPlumbing { + execute(options: ExecuteOptions): Promise; + executeStream(options: ExecuteOptions): Promise; + } +} + +declare module 'bun' { + export class CryptoHasher { + constructor(algorithm: string); + update(data: Buffer | Uint8Array | string): this; + digest(encoding: 'hex'): string; + digest(): Uint8Array; + } +} diff --git a/tsconfig.checkjs.json b/tsconfig.checkjs.json new file mode 100644 index 0000000..c8630b1 --- /dev/null +++ b/tsconfig.checkjs.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "checkJs": true, + "allowJs": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "declaration": false, + "noImplicitAny": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "exactOptionalPropertyTypes": false + }, + "include": [ + "index.js", + "src/**/*.js", + "src/**/*.d.ts", + "index.d.ts", + "src/types/ambient.d.ts" + ], + "exclude": [ + "node_modules", + "test", + "examples", + "bin" + ] +} From 82bd7dc1546e9586285e6834f60e9a5466bc555c Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 28 Feb 2026 10:16:02 -0800 Subject: [PATCH 2/4] jsdoc(cli): add type annotations to all bin/ files --- bin/actions.js | 17 +++--- bin/git-cas.js | 89 +++++++++++++++++++--------- bin/ui/context.js | 13 +++++ bin/ui/dashboard-cmds.js | 20 +++++-- bin/ui/dashboard-view.js | 61 +++++++++++++++++-- bin/ui/dashboard.js | 116 ++++++++++++++++++++++++++++++++++--- bin/ui/encryption-card.js | 4 +- bin/ui/heatmap.js | 22 ++++++- bin/ui/history-timeline.js | 14 +++-- bin/ui/manifest-view.js | 41 +++++++++++-- bin/ui/progress.js | 98 +++++++++++++++++++------------ src/types/ambient.d.ts | 9 +++ tsconfig.checkjs.json | 6 +- 13 files changed, 400 insertions(+), 110 deletions(-) diff --git a/bin/actions.js b/bin/actions.js index c4d58ec..f4663cc 100644 --- a/bin/actions.js +++ b/bin/actions.js @@ -2,6 +2,8 @@ * CLI error handler — wraps command actions with structured error output. */ +/** @typedef {{ code?: string, message?: string }} ErrorLike */ + const HINTS = { MISSING_KEY: 'Provide --key-file or --vault-passphrase', MANIFEST_NOT_FOUND: 'Verify the tree OID contains a manifest', @@ -19,20 +21,21 @@ const HINTS = { /** * Format and write an error to stderr. * - * @param {Error} err + * @param {ErrorLike} err * @param {boolean} json - Whether to output JSON. */ function writeError(err, json) { const message = err?.message ?? String(err); const code = typeof err?.code === 'string' ? err.code : undefined; if (json) { + /** @type {{ error: string, code?: string }} */ const obj = { error: message }; if (code) { obj.code = code; } process.stderr.write(`${JSON.stringify(obj)}\n`); } else { const prefix = code ? `error [${code}]: ` : 'error: '; process.stderr.write(`${prefix}${message}\n`); - const hint = code ? HINTS[code] : undefined; + const hint = code ? HINTS[/** @type {keyof HINTS} */ (code)] : undefined; if (hint) { process.stderr.write(`hint: ${hint}\n`); } @@ -42,15 +45,15 @@ function writeError(err, json) { /** * Wrap a command action with structured error handling. * - * @param {Function} fn - The async action function. - * @param {Function} getJson - Lazy getter for --json flag value. - * @returns {Function} Wrapped action. + * @param {(...args: any[]) => Promise} fn - The async action function. + * @param {() => boolean} getJson - Lazy getter for --json flag value. + * @returns {(...args: any[]) => Promise} Wrapped action. */ export function runAction(fn, getJson) { - return async (...args) => { + return async (/** @type {any[]} */ ...args) => { try { await fn(...args); - } catch (err) { + } catch (/** @type {any} */ err) { writeError(err, getJson()); process.exitCode = 1; } diff --git a/bin/git-cas.js b/bin/git-cas.js index f572745..0d08a5f 100755 --- a/bin/git-cas.js +++ b/bin/git-cas.js @@ -24,6 +24,9 @@ program /** * Read a 32-byte raw encryption key from a file. + * + * @param {string} keyFilePath + * @returns {Buffer} */ function readKeyFile(keyFilePath) { return readFileSync(keyFilePath); @@ -31,22 +34,31 @@ function readKeyFile(keyFilePath) { /** * Create a CAS instance for the given working directory with an optional observability adapter. + * + * @param {string} cwd + * @param {{ observability?: import('../index.js').ObservabilityPort }} [opts] + * @returns {ContentAddressableStore} */ -function createCas(cwd, { observability } = {}) { +function createCas(cwd, opts = {}) { const runner = ShellRunnerFactory.create(); const plumbing = new GitPlumbing({ runner, cwd }); - return new ContentAddressableStore({ plumbing, observability }); + return new ContentAddressableStore({ plumbing, observability: opts.observability }); } /** * Derive the encryption key from vault metadata + passphrase. + * + * @param {ContentAddressableStore} cas + * @param {import('../index.js').VaultMetadata} metadata + * @param {string} passphrase + * @returns {Promise} */ async function deriveVaultKey(cas, metadata, passphrase) { - const { kdf } = metadata.encryption; + const { kdf } = /** @type {NonNullable} */ (metadata.encryption); const { key } = await cas.deriveKey({ passphrase, salt: Buffer.from(kdf.salt, 'base64'), - algorithm: kdf.algorithm, + algorithm: /** @type {"pbkdf2" | "scrypt"} */ (kdf.algorithm), iterations: kdf.iterations, cost: kdf.cost, blockSize: kdf.blockSize, @@ -57,6 +69,9 @@ async function deriveVaultKey(cas, metadata, passphrase) { /** * Resolve passphrase from --vault-passphrase flag or GIT_CAS_PASSPHRASE env var. + * + * @param {Record} opts + * @returns {string | undefined} */ function resolvePassphrase(opts) { return opts.vaultPassphrase ?? process.env.GIT_CAS_PASSPHRASE; @@ -64,6 +79,10 @@ function resolvePassphrase(opts) { /** * Resolve encryption key from --key-file or --vault-passphrase / GIT_CAS_PASSPHRASE. + * + * @param {ContentAddressableStore} cas + * @param {Record} opts + * @returns {Promise} */ async function resolveEncryptionKey(cas, opts) { if (opts.keyFile) { @@ -83,6 +102,8 @@ async function resolveEncryptionKey(cas, opts) { /** * Validate --slug / --oid flags (exactly one required). + * + * @param {Record} opts */ function validateRestoreFlags(opts) { if (opts.slug && opts.oid) { @@ -98,8 +119,13 @@ function validateRestoreFlags(opts) { // --------------------------------------------------------------------------- /** * Build store options, resolving encryption key or recipients. + * + * @param {ContentAddressableStore} cas + * @param {string} file + * @param {Record} opts */ async function buildStoreOpts(cas, file, opts) { + /** @type {Record} */ const storeOpts = { filePath: file, slug: opts.slug }; if (opts.recipient) { storeOpts.recipients = opts.recipient; @@ -113,6 +139,10 @@ async function buildStoreOpts(cas, file, opts) { /** * Parse a --recipient flag value into { label, key }. * Format: label:keyfile + * + * @param {string} value + * @param {Array<{ label: string, key: Buffer }>} [previous] + * @returns {Array<{ label: string, key: Buffer }>} */ function parseRecipient(value, previous) { const sep = value.indexOf(':'); @@ -140,7 +170,7 @@ program .option('--force', 'Overwrite existing vault entry') .option('--vault-passphrase ', 'Vault-level passphrase for encryption (prefer GIT_CAS_PASSPHRASE env var)') .option('--cwd ', 'Git working directory', '.') - .action(runAction(async (file, opts) => { + .action(runAction(async (/** @type {string} */ file, /** @type {Record} */ opts) => { if (opts.recipient && (opts.keyFile || resolvePassphrase(opts))) { throw new Error('Provide --key-file/--vault-passphrase or --recipient, not both'); } @@ -156,7 +186,7 @@ program const progress = createStoreProgress({ filePath: file, chunkSize: cas.chunkSize, quiet }); progress.attach(observer); let manifest; - try { manifest = await cas.storeFile(storeOpts); } finally { progress.detach(); } + try { manifest = await cas.storeFile(/** @type {any} */ (storeOpts)); } finally { progress.detach(); } if (opts.tree) { const treeOid = await cas.createTree({ manifest }); @@ -176,7 +206,7 @@ program .description('Create a Git tree from a manifest') .requiredOption('--manifest ', 'Path to manifest JSON file') .option('--cwd ', 'Git working directory', '.') - .action(runAction(async (opts) => { + .action(runAction(async (/** @type {Record} */ opts) => { const cas = createCas(opts.cwd); const raw = readFileSync(opts.manifest, 'utf8'); const manifest = new Manifest(JSON.parse(raw)); @@ -199,7 +229,7 @@ program .option('--oid ', 'Direct tree OID') .option('--heatmap', 'Show chunk heatmap visualization') .option('--cwd ', 'Git working directory', '.') - .action(runAction(async (opts) => { + .action(runAction(async (/** @type {Record} */ opts) => { validateRestoreFlags(opts); const cas = createCas(opts.cwd); const treeOid = opts.oid || await cas.resolveVaultEntry({ slug: opts.slug }); @@ -229,7 +259,7 @@ program .option('--key-file ', 'Path to 32-byte raw encryption key file') .option('--vault-passphrase ', 'Vault-level passphrase for decryption (prefer GIT_CAS_PASSPHRASE env var)') .option('--cwd ', 'Git working directory', '.') - .action(runAction(async (opts) => { + .action(runAction(async (/** @type {Record} */ opts) => { validateRestoreFlags(opts); const quiet = program.opts().quiet || program.opts().json; const observer = new EventEmitterObserver(); @@ -237,6 +267,7 @@ program const treeOid = opts.oid || await cas.resolveVaultEntry({ slug: opts.slug }); const manifest = await cas.readManifest({ treeOid }); + /** @type {Record} */ const restoreOpts = { manifest }; const encryptionKey = await resolveEncryptionKey(cas, opts); if (encryptionKey) { @@ -249,10 +280,10 @@ program progress.attach(observer); let bytesWritten; try { - ({ bytesWritten } = await cas.restoreFile({ + ({ bytesWritten } = await cas.restoreFile(/** @type {any} */ ({ ...restoreOpts, outputPath: opts.out, - })); + }))); } finally { progress.detach(); } @@ -273,7 +304,7 @@ program .option('--slug ', 'Resolve tree OID from vault slug') .option('--oid ', 'Direct tree OID') .option('--cwd ', 'Git working directory', '.') - .action(runAction(async (opts) => { + .action(runAction(async (/** @type {Record} */ opts) => { validateRestoreFlags(opts); const cas = createCas(opts.cwd); const treeOid = opts.oid || await cas.resolveVaultEntry({ slug: opts.slug }); @@ -303,8 +334,9 @@ vault .option('--vault-passphrase ', 'Passphrase for vault-level encryption (prefer GIT_CAS_PASSPHRASE env var)') .option('--algorithm ', 'KDF algorithm (pbkdf2 or scrypt)', 'pbkdf2') .option('--cwd ', 'Git working directory', '.') - .action(runAction(async (opts) => { + .action(runAction(async (/** @type {Record} */ opts) => { const cas = createCas(opts.cwd); + /** @type {Record} */ const initOpts = {}; const passphrase = resolvePassphrase(opts); if (passphrase) { @@ -328,7 +360,7 @@ vault .description('List vault entries') .option('--filter ', 'Filter entries by glob pattern') .option('--cwd ', 'Git working directory', '.') - .action(runAction(async (opts) => { + .action(runAction(async (/** @type {Record} */ opts) => { const cas = createCas(opts.cwd); const all = await cas.listVault(); const entries = filterEntries(all, opts.filter); @@ -349,7 +381,7 @@ vault .command('remove ') .description('Remove an entry from the vault') .option('--cwd ', 'Git working directory', '.') - .action(runAction(async (slug, opts) => { + .action(runAction(async (/** @type {string} */ slug, /** @type {Record} */ opts) => { const cas = createCas(opts.cwd); const { commitOid, removedTreeOid } = await cas.removeFromVault({ slug }); const json = program.opts().json; @@ -368,11 +400,12 @@ vault .description('Show info for a vault entry') .option('--encryption', 'Show vault encryption details') .option('--cwd ', 'Git working directory', '.') - .action(runAction(async (slug, opts) => { + .action(runAction(async (/** @type {string} */ slug, /** @type {Record} */ opts) => { const cas = createCas(opts.cwd); const treeOid = await cas.resolveVaultEntry({ slug }); const json = program.opts().json; if (json) { + /** @type {Record} */ const result = { slug, treeOid }; if (opts.encryption) { const metadata = await cas.getVaultMetadata(); @@ -400,7 +433,7 @@ vault .option('--cwd ', 'Git working directory', '.') .option('-n, --max-count ', 'Limit number of commits') .option('--pretty', 'Render as color-coded timeline') - .action(runAction(async (opts) => { + .action(runAction(async (/** @type {Record} */ opts) => { const runner = ShellRunnerFactory.create(); const plumbing = new GitPlumbing({ runner, cwd: opts.cwd || '.' }); const args = ['log', '--oneline', ContentAddressableStore.VAULT_REF]; @@ -417,7 +450,7 @@ vault const history = output .split('\n') .filter(Boolean) - .map((line) => { + .map((/** @type {string} */ line) => { const [commitOid, ...messageParts] = line.trim().split(/\s+/); return { commitOid, message: messageParts.join(' ') }; }); @@ -439,8 +472,9 @@ vault .requiredOption('--new-passphrase ', 'New vault passphrase') .option('--algorithm ', 'KDF algorithm (pbkdf2 or scrypt)') .option('--cwd ', 'Git working directory', '.') - .action(runAction(async (opts) => { + .action(runAction(async (/** @type {Record} */ opts) => { const cas = createCas(opts.cwd); + /** @type {Record} */ const rotateOpts = { oldPassphrase: opts.oldPassphrase, newPassphrase: opts.newPassphrase, @@ -448,7 +482,7 @@ vault if (opts.algorithm) { rotateOpts.kdfOptions = { algorithm: opts.algorithm }; } - const { commitOid, rotatedSlugs, skippedSlugs } = await cas.rotateVaultPassphrase(rotateOpts); + const { commitOid, rotatedSlugs, skippedSlugs } = await cas.rotateVaultPassphrase(/** @type {any} */ (rotateOpts)); const json = program.opts().json; if (json) { process.stdout.write(`${JSON.stringify({ commitOid, rotatedSlugs, skippedSlugs })}\n`); @@ -470,7 +504,7 @@ vault .command('dashboard') .description('Interactive vault explorer') .option('--cwd ', 'Git working directory', '.') - .action(runAction(async (opts) => { + .action(runAction(async (/** @type {Record} */ opts) => { const cas = createCas(opts.cwd); const { launchDashboard } = await import('./ui/dashboard.js'); await launchDashboard(cas); @@ -488,7 +522,7 @@ program .requiredOption('--new-key-file ', 'Path to new 32-byte key file') .option('--label