From dabc94f811f2357ef253936b668599375a268d02 Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Tue, 21 Apr 2026 21:56:56 -0700 Subject: [PATCH 01/23] spec: add MVP implementation plan Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- spec/plan.md | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 spec/plan.md diff --git a/spec/plan.md b/spec/plan.md new file mode 100644 index 0000000..b639fc0 --- /dev/null +++ b/spec/plan.md @@ -0,0 +1,115 @@ +# Workspace Vault — MVP Implementation Plan + +## Architecture Overview + +``` +src/ + crypto/ — age encryption: encrypt/decrypt buffers, key generation, master key wrap/unwrap + vault/ — vault init, file CRUD (encrypt/decrypt on disk), metadata (SQLite) + session/ — daemon/broker: holds master key in memory, local socket IPC + audit/ — append-only audit log (SQLite table) + security/ — path validation, input sanitization (Zod), output sanitization, file permissions + config/ — vault config (~/.config/workspace-vault/config.json), vault path pointer + cli/ — CLI commands (commander.js) + mcp/ — MCP server (thin wrapper, talks to daemon) + types.ts — shared types and error definitions + index.ts — public API +test/ + unit/ — unit tests per module + integration/ — end-to-end CLI + MCP workflows +``` + +## Key Design Decisions + +### Unlock model: Daemon/broker +- `vault unlock` prompts for passphrase, unwraps master key, starts a background daemon on a local socket (Unix) or named pipe (Windows) +- The daemon holds the master key in memory — it never leaves this process +- CLI commands and MCP server connect to the daemon for operations requiring the master key +- `vault lock` tells the daemon to drop the key and shut down +- Timeout: daemon auto-locks after configurable period (default 30 min) +- CLI commands that only need metadata (list, status) work without the daemon + +### Metadata: SQLite (via better-sqlite3) +- Single SQLite database in the vault directory +- Tables: files (metadata), keys (authorized key records) +- Audit log: separate SQLite table (append-only by convention) +- Daemon is the primary writer; CLI reads metadata directly for lock-safe operations + +### Encrypted file storage +- Each file stored as `/files/.age` (age-encrypted blob) +- Metadata in SQLite maps original path → UUID → encryption metadata +- Master key generated on `vault init`, wrapped for each authorized key + +### Auth: Passphrase only (MVP) +- SSH key support deferred to post-MVP +- Passphrase → age identity via scrypt-based key derivation + +### Config: Plain JSON +- `~/.config/workspace-vault/config.json` — stores vault path, daemon socket path +- Not encrypted for MVP (vault path is not treated as a high-value secret in MVP) +- Restrictive file permissions (POSIX 0600) + +## MVP Scope (what we build) + +### CLI Commands +- `vault init` — create vault, set passphrase, print MCP config snippet +- `vault unlock` — prompt passphrase, start daemon, hold master key +- `vault lock` — tell daemon to drop key, shut down +- `vault status` — locked/unlocked, file count, key count +- `vault write [--from ]` — encrypt and store (stdin or file) +- `vault read ` — decrypt and print to stdout +- `vault delete ` — remove encrypted file + metadata +- `vault list [vault-path]` — list metadata (works while locked) +- `vault search ` — search filenames/tags (works while locked) +- `vault grep ` — grep decrypted content (requires unlock) +- `vault key add` — add passphrase-based key +- `vault key list` — list authorized keys +- `vault key revoke ` — delete wrapped master key copy +- `vault audit [--tail N]` — show audit log + +### MCP Tools (agent-facing) +- `vault_read_file` — read decrypted content (requires unlock) +- `vault_create_file` — create encrypted file (requires unlock) +- `vault_list_dir` — list file metadata (works while locked) +- `vault_grep_search` — grep content (requires unlock) +- `vault_file_search` — search filenames (works while locked) + +### What's cut from MVP +- `vault edit` — plaintext temp file risk; deferred +- SSH key auth — passphrase only for now +- Encrypted config — plain JSON with restrictive permissions +- `vault_replace_string_in_file` — requires edit capability; deferred +- True cryptographic revocation — MVP revoke prevents future unlocks only + +## Implementation Phases + +### Phase 1: Project Scaffold +- package.json, tsconfig.json, ESLint, Vitest, Prettier +- Directory structure with barrel exports +- CI: GitHub Actions for build + test + lint + +### Phase 2: Foundation (parallelizable) +- **crypto**: age encrypt/decrypt, passphrase-to-identity, master key generation, wrap/unwrap +- **security**: path validation, Zod schemas, output sanitization, file permissions +- **types**: error types, operation types, shared interfaces + +### Phase 3: Core Engine (depends on Phase 2) +- **config**: config CRUD, vault path management, first-run detection +- **vault**: init (create structure + master key + wrap), metadata SQLite, file write/read/delete/list +- **audit**: SQLite audit log, event recording + +### Phase 4: Session Layer (depends on Phase 2) +- **session/daemon**: local socket/named pipe server, master key holder, timeout, lock/unlock protocol +- **session/client**: client library for CLI + MCP to talk to daemon + +### Phase 5: Interfaces (depends on Phase 3 + 4, parallelizable) +- **CLI**: all commands using commander.js, interactive passphrase prompt +- **MCP**: server setup with @modelcontextprotocol/sdk, tool definitions, locked-state errors + +### Phase 6: Integration Tests (depends on Phase 5) +- End-to-end CLI workflows +- MCP server locked/unlocked behavior +- Key add/revoke scenarios +- Path traversal rejection tests +- Audit log completeness +- Cross-platform smoke tests From 41cd84028ed3d054821f3214ec603e437a67fa9a Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Tue, 21 Apr 2026 21:58:39 -0700 Subject: [PATCH 02/23] scaffold: project setup with TypeScript, ESLint, Vitest, Prettier - package.json with dependencies (age-encryption, MCP SDK, commander, better-sqlite3, zod) - TypeScript strict config targeting ES2022/Node16 - ESLint flat config with typescript-eslint strict - Vitest for testing with v8 coverage - Directory structure for all modules - Placeholder barrel exports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .prettierrc | 7 + eslint.config.js | 17 + package-lock.json | 4067 +++++++++++++++++++++++++++++++++++++ package.json | 60 + src/audit/index.ts | 1 + src/cli/index.ts | 2 + src/config/index.ts | 1 + src/crypto/index.ts | 1 + src/index.ts | 2 + src/mcp/index.ts | 1 + src/security/index.ts | 1 + src/session/index.ts | 1 + src/types.ts | 2 + src/vault/index.ts | 1 + test/integration/.gitkeep | 0 test/unit/.gitkeep | 0 test/unit/smoke.test.ts | 7 + tsconfig.json | 21 + vitest.config.ts | 12 + 19 files changed, 4204 insertions(+) create mode 100644 .prettierrc create mode 100644 eslint.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/audit/index.ts create mode 100644 src/cli/index.ts create mode 100644 src/config/index.ts create mode 100644 src/crypto/index.ts create mode 100644 src/index.ts create mode 100644 src/mcp/index.ts create mode 100644 src/security/index.ts create mode 100644 src/session/index.ts create mode 100644 src/types.ts create mode 100644 src/vault/index.ts create mode 100644 test/integration/.gitkeep create mode 100644 test/unit/.gitkeep create mode 100644 test/unit/smoke.test.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..4cbc711 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2 +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..14c1c70 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,17 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.strict, + { + rules: { + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'error', + }, + }, + { + ignores: ['dist/', 'node_modules/', '*.js'], + }, +); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e63bdf6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4067 @@ +{ + "name": "workspace-vault", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "workspace-vault", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "age-encryption": "^0.3.0", + "better-sqlite3": "^12.9.0", + "commander": "^14.0.3", + "zod": "^4.3.6" + }, + "bin": { + "vault": "dist/cli/index.js" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^25.6.0", + "eslint": "^10.2.1", + "prettier": "^3.8.3", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.0", + "vitest": "^4.1.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.5.4.tgz", + "integrity": "sha512-leww0zzIirrvwaYMPI9fj6aRIlA/c6Y0/lifQQ1YOOyHEr0MNH3yYpjXeiVG+tWdPps4XxGclFWX2INPO3Yo5w==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~2.0.0", + "@noble/hashes": "~2.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum/node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.126.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz", + "integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz", + "integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz", + "integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz", + "integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", + "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/type-utils": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", + "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", + "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", + "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", + "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/age-encryption": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/age-encryption/-/age-encryption-0.3.0.tgz", + "integrity": "sha512-vDhGGp5ZXpbKL6oc3FEBjGhyepr/0SwIWmia6CcPXvYcxGBSQNcDdMv9m2yVOkjjYuJEmtGEhOojIUcjrj0cEw==", + "license": "BSD-3-Clause", + "dependencies": { + "@noble/ciphers": "^2.1.1", + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", + "@noble/post-quantum": "^0.5.3", + "@scure/base": "^2.0.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.9.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", + "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz", + "integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.126.0", + "@rolldown/pluginutils": "1.0.0-rc.16" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.16", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", + "@rolldown/binding-darwin-x64": "1.0.0-rc.16", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz", + "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.0", + "@typescript-eslint/parser": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz", + "integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.16", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ef87ace --- /dev/null +++ b/package.json @@ -0,0 +1,60 @@ +{ + "name": "workspace-vault", + "version": "0.1.0", + "description": "Encrypted file vault with CLI and MCP server for AI agent access to private files", + "type": "module", + "license": "MIT", + "author": "ThingsAI", + "repository": { + "type": "git", + "url": "https://github.com/ThingsAI-io/workspace-vault.git" + }, + "bin": { + "vault": "./dist/cli/index.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"", + "test": "vitest run", + "test:watch": "vitest", + "prepublishOnly": "npm run build" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "vault", + "encryption", + "mcp", + "cli", + "age", + "ai-agent", + "privacy" + ], + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "age-encryption": "^0.3.0", + "better-sqlite3": "^12.9.0", + "commander": "^14.0.3", + "zod": "^4.3.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^25.6.0", + "eslint": "^10.2.1", + "prettier": "^3.8.3", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.0", + "vitest": "^4.1.5" + } +} diff --git a/src/audit/index.ts b/src/audit/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/audit/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..cdb40bb --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +// CLI entry point diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/crypto/index.ts b/src/crypto/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/crypto/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..2badf4a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +// Public API +export {}; diff --git a/src/mcp/index.ts b/src/mcp/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/mcp/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/security/index.ts b/src/security/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/security/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/session/index.ts b/src/session/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/session/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..d501250 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,2 @@ +// Shared types - to be implemented +export {}; diff --git a/src/vault/index.ts b/src/vault/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/vault/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/test/integration/.gitkeep b/test/integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/.gitkeep b/test/unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/smoke.test.ts b/test/unit/smoke.test.ts new file mode 100644 index 0000000..5d540d0 --- /dev/null +++ b/test/unit/smoke.test.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from 'vitest'; + +describe('workspace-vault', () => { + it('should be true', () => { + expect(true).toBe(true); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..34aa671 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "isolatedModules": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..b00db7c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + include: ['test/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + }, + }, +}); From bcfab426c1e33ddf10075725a032e1b1c343cfa9 Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Tue, 21 Apr 2026 22:00:16 -0700 Subject: [PATCH 03/23] ci: GitHub Actions workflow for build, lint, test on 3 platforms - Matrix: ubuntu-latest, macos-latest, windows-latest - Node.js 20 - Steps: install, build, lint, test, format check Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..76de105 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: [main, feat/*] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + build-and-test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node-version: [20] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Lint + run: npm run lint + + - name: Test + run: npm test + + - name: Format check + run: npm run format:check From 385df0e8850b4c3459f0e0278e246865fb88d065 Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Tue, 21 Apr 2026 22:01:13 -0700 Subject: [PATCH 04/23] types: shared types, error hierarchy, Zod schemas, daemon protocol - VaultError hierarchy with typed error codes - OperationType enum for audit trail - FileMetadata, KeyRecord, AuditEntry, VaultConfig interfaces - Zod schemas for all CLI/MCP input validation - DaemonCommand/Request/Response protocol types Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/index.ts | 2 +- src/types.ts | 248 +++++++++++++++++++++++++++++++++++++++++++++++++- tsconfig.json | 1 + 3 files changed, 248 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2badf4a..f2e97eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ // Public API -export {}; +export * from './types.js'; diff --git a/src/types.ts b/src/types.ts index d501250..639e9eb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,2 +1,246 @@ -// Shared types - to be implemented -export {}; +import { z } from 'zod/v4'; + +// ── Error hierarchy ───────────────────────────────────────────────────────── + +export class VaultError extends Error { + constructor( + message: string, + public readonly code: string, + ) { + super(message); + this.name = 'VaultError'; + } +} + +export class VaultNotInitializedError extends VaultError { + constructor(message = 'Vault has not been initialized') { + super(message, 'VAULT_NOT_INITIALIZED'); + this.name = 'VaultNotInitializedError'; + } +} + +export class VaultLockedError extends VaultError { + constructor(message = 'Vault is locked') { + super(message, 'VAULT_LOCKED'); + this.name = 'VaultLockedError'; + } +} + +export class VaultAlreadyUnlockedError extends VaultError { + constructor(message = 'Vault is already unlocked') { + super(message, 'VAULT_ALREADY_UNLOCKED'); + this.name = 'VaultAlreadyUnlockedError'; + } +} + +export class PathTraversalError extends VaultError { + constructor(message = 'Path traversal or invalid path detected') { + super(message, 'PATH_TRAVERSAL'); + this.name = 'PathTraversalError'; + } +} + +export class FileNotFoundError extends VaultError { + constructor(message = 'File not found in vault') { + super(message, 'FILE_NOT_FOUND'); + this.name = 'FileNotFoundError'; + } +} + +export class FileAlreadyExistsError extends VaultError { + constructor(message = 'File already exists in vault') { + super(message, 'FILE_ALREADY_EXISTS'); + this.name = 'FileAlreadyExistsError'; + } +} + +export class KeyNotFoundError extends VaultError { + constructor(message = 'Key not found') { + super(message, 'KEY_NOT_FOUND'); + this.name = 'KeyNotFoundError'; + } +} + +export class InvalidKeyError extends VaultError { + constructor(message = 'Invalid passphrase or key') { + super(message, 'INVALID_KEY'); + this.name = 'InvalidKeyError'; + } +} + +export class LastKeyError extends VaultError { + constructor(message = 'Cannot revoke the last remaining key') { + super(message, 'LAST_KEY'); + this.name = 'LastKeyError'; + } +} + +export class DaemonNotRunningError extends VaultError { + constructor(message = 'Daemon not running or unreachable') { + super(message, 'DAEMON_NOT_RUNNING'); + this.name = 'DaemonNotRunningError'; + } +} + +export class DaemonError extends VaultError { + constructor(message = 'Daemon communication error') { + super(message, 'DAEMON_ERROR'); + this.name = 'DaemonError'; + } +} + +// ── Operation types ───────────────────────────────────────────────────────── + +export enum OperationType { + READ = 'read', + WRITE = 'write', + DELETE = 'delete', + LIST = 'list', + SEARCH = 'search', + GREP = 'grep', + UNLOCK = 'unlock', + LOCK = 'lock', + KEY_ADD = 'key_add', + KEY_REVOKE = 'key_revoke', + INIT = 'init', +} + +// ── Interfaces ────────────────────────────────────────────────────────────── + +export interface FileMetadata { + id: string; + vaultPath: string; + blobId: string; + size: number; + createdAt: Date; + modifiedAt: Date; + tags: string[]; +} + +export interface KeyRecord { + id: string; + label: string; + createdAt: Date; +} + +export interface AuditEntry { + id: number; + timestamp: Date; + operation: OperationType; + targetPath?: string; + keyId?: string; + success: boolean; +} + +export interface VaultConfig { + vaultPath: string; + socketPath: string; + version: number; +} + +export interface VaultStatus { + initialized: boolean; + locked: boolean; + fileCount: number; + keyCount: number; +} + +export interface GrepResult { + vaultPath: string; + lineNumber: number; + line: string; +} + +export interface SearchResult { + vaultPath: string; + matchType: 'filename' | 'tag'; + matchedValue: string; +} + +// ── Zod schemas (input validation) ────────────────────────────────────────── + +export const VaultPathSchema = z + .string() + .min(1) + .max(1024) + .refine((val) => !val.includes('\0'), 'Path must not contain null bytes'); + +export const PassphraseSchema = z.string().min(8).max(1024); + +export const LabelSchema = z.string().min(1).max(256).trim(); + +export const TagSchema = z.string().min(1).max(128).trim(); + +export const SearchQuerySchema = z.string().min(1).max(512); + +export const GrepPatternSchema = z.string().min(1).max(512); + +export const InitInputSchema = z.object({ + vaultPath: z.string().min(1), + passphrase: PassphraseSchema, +}); + +export const WriteInputSchema = z.object({ + vaultPath: VaultPathSchema, + content: z.instanceof(Buffer).optional(), + fromFile: z.string().optional(), + tags: z.array(TagSchema).optional(), +}); + +export const ReadInputSchema = z.object({ + vaultPath: VaultPathSchema, +}); + +export const DeleteInputSchema = z.object({ + vaultPath: VaultPathSchema, +}); + +export const ListInputSchema = z.object({ + vaultPath: VaultPathSchema.optional(), +}); + +export const SearchInputSchema = z.object({ + query: SearchQuerySchema, +}); + +export const GrepInputSchema = z.object({ + pattern: GrepPatternSchema, +}); + +export const KeyAddInputSchema = z.object({ + passphrase: PassphraseSchema, + label: LabelSchema, +}); + +export const KeyRevokeInputSchema = z.object({ + keyId: z.string().uuid(), +}); + +export const AuditQuerySchema = z.object({ + tail: z.number().int().positive().max(10000).optional(), + operation: z.nativeEnum(OperationType).optional(), +}); + +// ── Daemon protocol ───────────────────────────────────────────────────────── + +export enum DaemonCommand { + UNLOCK = 'unlock', + LOCK = 'lock', + STATUS = 'status', + READ_FILE = 'read_file', + WRITE_FILE = 'write_file', + DELETE_FILE = 'delete_file', + GREP = 'grep', +} + +export interface DaemonRequest { + command: DaemonCommand; + payload?: Record; +} + +export interface DaemonResponse { + success: boolean; + data?: unknown; + error?: string; + errorCode?: string; +} diff --git a/tsconfig.json b/tsconfig.json index 34aa671..bbc1211 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "module": "Node16", "moduleResolution": "Node16", "lib": ["ES2022"], + "types": ["node"], "outDir": "./dist", "rootDir": "./src", "strict": true, From 6cee78a1ac3e04a3e9d649b12ba4e614c9fdefd1 Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 11:27:00 -0700 Subject: [PATCH 05/23] types: shared types, error hierarchy, Zod schemas, session protocol - VaultError hierarchy with typed error codes (session-file model, no daemon) - OperationType enum for audit trail - FileMetadata, KeyRecord, AuditEntry, VaultConfig, SessionData interfaces - Zod schemas for all CLI/MCP input validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- spec/plan.md | 28 ++++++++++++++-------------- src/types.ts | 44 ++++++++++++++------------------------------ 2 files changed, 28 insertions(+), 44 deletions(-) diff --git a/spec/plan.md b/spec/plan.md index b639fc0..8487465 100644 --- a/spec/plan.md +++ b/spec/plan.md @@ -6,12 +6,12 @@ src/ crypto/ — age encryption: encrypt/decrypt buffers, key generation, master key wrap/unwrap vault/ — vault init, file CRUD (encrypt/decrypt on disk), metadata (SQLite) - session/ — daemon/broker: holds master key in memory, local socket IPC + session/ — session file: master key persistence between CLI and MCP server audit/ — append-only audit log (SQLite table) security/ — path validation, input sanitization (Zod), output sanitization, file permissions config/ — vault config (~/.config/workspace-vault/config.json), vault path pointer cli/ — CLI commands (commander.js) - mcp/ — MCP server (thin wrapper, talks to daemon) + mcp/ — MCP server (thin wrapper, reads session file for master key) types.ts — shared types and error definitions index.ts — public API test/ @@ -21,19 +21,20 @@ test/ ## Key Design Decisions -### Unlock model: Daemon/broker -- `vault unlock` prompts for passphrase, unwraps master key, starts a background daemon on a local socket (Unix) or named pipe (Windows) -- The daemon holds the master key in memory — it never leaves this process -- CLI commands and MCP server connect to the daemon for operations requiring the master key -- `vault lock` tells the daemon to drop the key and shut down -- Timeout: daemon auto-locks after configurable period (default 30 min) -- CLI commands that only need metadata (list, status) work without the daemon +### Unlock model: Session file +- `vault unlock` prompts for passphrase, unwraps master key, writes it to a session file with restrictive permissions (POSIX 0600) +- The MCP server reads the session file on demand when it needs the master key +- `vault lock` deletes the session file, zeroing contents first (best-effort) +- Timeout: session file includes a TTL timestamp; readers check expiry and treat expired sessions as locked +- CLI commands that only need metadata (list, status) work without a session file +- Session file location: `~/.config/workspace-vault/session` (same restrictive-permission config dir) +- Same proven pattern as SSH agent sockets — simple, cross-platform, no extra processes ### Metadata: SQLite (via better-sqlite3) - Single SQLite database in the vault directory - Tables: files (metadata), keys (authorized key records) - Audit log: separate SQLite table (append-only by convention) -- Daemon is the primary writer; CLI reads metadata directly for lock-safe operations +- Both CLI and MCP server access SQLite directly for metadata operations ### Encrypted file storage - Each file stored as `/files/.age` (age-encrypted blob) @@ -45,7 +46,7 @@ test/ - Passphrase → age identity via scrypt-based key derivation ### Config: Plain JSON -- `~/.config/workspace-vault/config.json` — stores vault path, daemon socket path +- `~/.config/workspace-vault/config.json` — stores vault path, session file path - Not encrypted for MVP (vault path is not treated as a high-value secret in MVP) - Restrictive file permissions (POSIX 0600) @@ -99,12 +100,11 @@ test/ - **audit**: SQLite audit log, event recording ### Phase 4: Session Layer (depends on Phase 2) -- **session/daemon**: local socket/named pipe server, master key holder, timeout, lock/unlock protocol -- **session/client**: client library for CLI + MCP to talk to daemon +- **session**: SessionManager class — write/read/clear session file, TTL-based expiry, restrictive file permissions ### Phase 5: Interfaces (depends on Phase 3 + 4, parallelizable) - **CLI**: all commands using commander.js, interactive passphrase prompt -- **MCP**: server setup with @modelcontextprotocol/sdk, tool definitions, locked-state errors +- **MCP**: server setup with @modelcontextprotocol/sdk, tool definitions, reads session file for master key, locked-state errors ### Phase 6: Integration Tests (depends on Phase 5) - End-to-end CLI workflows diff --git a/src/types.ts b/src/types.ts index 639e9eb..06b5fb4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -75,17 +75,17 @@ export class LastKeyError extends VaultError { } } -export class DaemonNotRunningError extends VaultError { - constructor(message = 'Daemon not running or unreachable') { - super(message, 'DAEMON_NOT_RUNNING'); - this.name = 'DaemonNotRunningError'; +export class SessionExpiredError extends VaultError { + constructor(message = 'Vault session has expired — run `vault unlock` again') { + super(message, 'SESSION_EXPIRED'); + this.name = 'SessionExpiredError'; } } -export class DaemonError extends VaultError { - constructor(message = 'Daemon communication error') { - super(message, 'DAEMON_ERROR'); - this.name = 'DaemonError'; +export class SessionFileError extends VaultError { + constructor(message = 'Session file error') { + super(message, 'SESSION_FILE_ERROR'); + this.name = 'SessionFileError'; } } @@ -134,7 +134,7 @@ export interface AuditEntry { export interface VaultConfig { vaultPath: string; - socketPath: string; + sessionPath: string; version: number; } @@ -221,26 +221,10 @@ export const AuditQuerySchema = z.object({ operation: z.nativeEnum(OperationType).optional(), }); -// ── Daemon protocol ───────────────────────────────────────────────────────── +// ── Session file types ─────────────────────────────────────────────────────── -export enum DaemonCommand { - UNLOCK = 'unlock', - LOCK = 'lock', - STATUS = 'status', - READ_FILE = 'read_file', - WRITE_FILE = 'write_file', - DELETE_FILE = 'delete_file', - GREP = 'grep', -} - -export interface DaemonRequest { - command: DaemonCommand; - payload?: Record; -} - -export interface DaemonResponse { - success: boolean; - data?: unknown; - error?: string; - errorCode?: string; +export interface SessionData { + masterKey: string; // hex-encoded master key + expiresAt: string; // ISO 8601 timestamp + createdAt: string; // ISO 8601 timestamp } From e2bf3aa9eb51412507425c305a621ac84a3dc127 Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 11:32:39 -0700 Subject: [PATCH 06/23] security: path validation, output sanitization, file permissions - Path traversal prevention with symlink detection - Windows-specific checks (ADS, reserved names, case-insensitive) - ANSI/control character stripping for terminal output - Restrictive file permissions (POSIX 0600/0700) - Comprehensive unit tests for all security utilities Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/audit/index.ts | 2 +- src/audit/logger.ts | 116 ++++++++++++++++ src/crypto/age.ts | 69 +++++++++ src/crypto/index.ts | 15 +- src/crypto/keys.ts | 109 +++++++++++++++ src/security/index.ts | 4 +- src/security/paths.ts | 95 +++++++++++++ src/security/permissions.ts | 18 +++ src/security/sanitize.ts | 46 ++++++ test/unit/audit.test.ts | 190 +++++++++++++++++++++++++ test/unit/crypto.test.ts | 162 ++++++++++++++++++++++ test/unit/security.test.ts | 270 ++++++++++++++++++++++++++++++++++++ 12 files changed, 1093 insertions(+), 3 deletions(-) create mode 100644 src/audit/logger.ts create mode 100644 src/crypto/age.ts create mode 100644 src/crypto/keys.ts create mode 100644 src/security/paths.ts create mode 100644 src/security/permissions.ts create mode 100644 src/security/sanitize.ts create mode 100644 test/unit/audit.test.ts create mode 100644 test/unit/crypto.test.ts create mode 100644 test/unit/security.test.ts diff --git a/src/audit/index.ts b/src/audit/index.ts index cb0ff5c..4e94e14 100644 --- a/src/audit/index.ts +++ b/src/audit/index.ts @@ -1 +1 @@ -export {}; +export { AuditLogger } from './logger.js'; diff --git a/src/audit/logger.ts b/src/audit/logger.ts new file mode 100644 index 0000000..683476b --- /dev/null +++ b/src/audit/logger.ts @@ -0,0 +1,116 @@ +import Database from 'better-sqlite3'; +import { OperationType, type AuditEntry } from '../types.js'; + +export class AuditLogger { + private db: Database.Database; + private insertStmt: Database.Statement; + + constructor(dbPath: string) { + this.db = new Database(dbPath); + + this.db.pragma('journal_mode = WAL'); + + this.db.exec(` + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + operation TEXT NOT NULL, + target_path TEXT, + key_id TEXT, + success INTEGER NOT NULL DEFAULT 1 + ) + `); + + this.insertStmt = this.db.prepare( + 'INSERT INTO audit_log (timestamp, operation, target_path, key_id, success) VALUES (?, ?, ?, ?, ?)', + ); + } + + /** + * Log an audit event. NEVER include file content in any field. + */ + logEvent(params: { + operation: OperationType; + targetPath?: string; + keyId?: string; + success?: boolean; + }): void { + const timestamp = new Date().toISOString(); + const success = params.success === undefined ? 1 : params.success ? 1 : 0; + this.insertStmt.run( + timestamp, + params.operation, + params.targetPath ?? null, + params.keyId ?? null, + success, + ); + } + + /** + * Query audit events with optional filtering. + */ + getEvents(params?: { + limit?: number; + operation?: OperationType; + since?: Date; + }): AuditEntry[] { + const conditions: string[] = []; + const values: unknown[] = []; + + if (params?.operation) { + conditions.push('operation = ?'); + values.push(params.operation); + } + + if (params?.since) { + conditions.push('timestamp >= ?'); + values.push(params.since.toISOString()); + } + + let sql = 'SELECT * FROM audit_log'; + if (conditions.length > 0) { + sql += ' WHERE ' + conditions.join(' AND '); + } + sql += ' ORDER BY id DESC'; + + if (params?.limit) { + sql += ' LIMIT ?'; + values.push(params.limit); + } + + const rows = this.db.prepare(sql).all(...values) as Array<{ + id: number; + timestamp: string; + operation: string; + target_path: string | null; + key_id: string | null; + success: number; + }>; + + return rows.map((row) => ({ + id: row.id, + timestamp: new Date(row.timestamp), + operation: row.operation as OperationType, + targetPath: row.target_path ?? undefined, + keyId: row.key_id ?? undefined, + success: row.success === 1, + })); + } + + /** + * Get total count of audit events. + */ + getEventCount(): number { + const row = this.db + .prepare('SELECT COUNT(*) AS count FROM audit_log') + .get() as { count: number }; + return row.count; + } + + /** + * Close the database connection. + */ + close(): void { + this.db.close(); + } +} diff --git a/src/crypto/age.ts b/src/crypto/age.ts new file mode 100644 index 0000000..7bbca55 --- /dev/null +++ b/src/crypto/age.ts @@ -0,0 +1,69 @@ +import { Encrypter, Decrypter } from 'age-encryption'; +import { InvalidKeyError } from '../types.js'; + +/** + * Encrypt a buffer for one or more age recipients (public keys). + */ +export async function encrypt( + plaintext: Buffer, + recipients: string[], +): Promise { + const e = new Encrypter(); + for (const r of recipients) { + e.addRecipient(r); + } + const ciphertext = await e.encrypt(plaintext); + return Buffer.from(ciphertext); +} + +/** + * Decrypt a buffer using an age identity (private key). + */ +export async function decrypt( + ciphertext: Buffer, + identity: string, +): Promise { + try { + const d = new Decrypter(); + d.addIdentity(identity); + const plaintext = await d.decrypt(ciphertext); + return Buffer.from(plaintext); + } catch (err) { + throw new InvalidKeyError( + err instanceof Error ? err.message : 'Decryption failed', + ); + } +} + +/** + * Encrypt a buffer using a passphrase (age scrypt-based). + */ +export async function encryptWithPassphrase( + plaintext: Buffer, + passphrase: string, +): Promise { + const e = new Encrypter(); + e.setPassphrase(passphrase); + e.setScryptWorkFactor(18); + const ciphertext = await e.encrypt(plaintext); + return Buffer.from(ciphertext); +} + +/** + * Decrypt a buffer using a passphrase (age scrypt-based). + */ +export async function decryptWithPassphrase( + ciphertext: Buffer, + passphrase: string, +): Promise { + try { + const d = new Decrypter(); + d.addPassphrase(passphrase); + const plaintext = await d.decrypt(ciphertext); + return Buffer.from(plaintext); + } catch (err) { + throw new InvalidKeyError( + err instanceof Error ? err.message : 'Decryption failed', + ); + } +} diff --git a/src/crypto/index.ts b/src/crypto/index.ts index cb0ff5c..8872fd4 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -1 +1,14 @@ -export {}; +export { + encrypt, + decrypt, + encryptWithPassphrase, + decryptWithPassphrase, +} from './age.js'; +export { + generateKeyPair, + passphraseToIdentity, + generateSalt, + wrapMasterKey, + unwrapMasterKey, + wrapMasterKeyWithPassphrase, +} from './keys.js'; diff --git a/src/crypto/keys.ts b/src/crypto/keys.ts new file mode 100644 index 0000000..6f064ef --- /dev/null +++ b/src/crypto/keys.ts @@ -0,0 +1,109 @@ +import { randomBytes } from 'node:crypto'; +import { + generateIdentity, + identityToRecipient, +} from 'age-encryption'; +import { InvalidKeyError } from '../types.js'; +import { + encrypt, + decrypt, + encryptWithPassphrase, + decryptWithPassphrase, +} from './age.js'; + +/** + * Generate a new age keypair for use as a master key. + */ +export async function generateKeyPair(): Promise<{ + publicKey: string; + privateKey: string; +}> { + const privateKey = await generateIdentity(); + const publicKey = await identityToRecipient(privateKey); + return { publicKey, privateKey }; +} + +/** + * Derive an age-compatible identity from a passphrase using age's native + * scrypt-based passphrase encryption. The salt is mixed into the passphrase + * to ensure uniqueness across vaults. + * + * Returns an opaque identity string (the combined passphrase+salt) and its + * corresponding "public key" (which is not a real public key — passphrase + * identities are symmetric). The identity is used with encryptWithPassphrase / + * decryptWithPassphrase rather than the X25519 encrypt/decrypt. + */ +export async function passphraseToIdentity( + passphrase: string, + salt: string, +): Promise<{ publicKey: string; identity: string }> { + // Combine passphrase and salt to create a deterministic, unique identity. + // age's scrypt recipient handles the actual KDF internally. + const identity = `${passphrase}:${salt}`; + + // Generate a deterministic keypair by using passphrase-encrypted master key. + // For passphrase-based identities we use the combined string directly. + // The "publicKey" here is a hash-based identifier for the passphrase identity. + const { createHash } = await import('node:crypto'); + const publicKey = `passphrase:${createHash('sha256').update(identity).digest('hex').slice(0, 16)}`; + + return { publicKey, identity }; +} + +/** + * Generate a random salt for passphrase derivation. + * Returns 32 random bytes, hex-encoded. + */ +export function generateSalt(): string { + return randomBytes(32).toString('hex'); +} + +/** + * Wrap (encrypt) a master key's private key for a recipient public key. + */ +export async function wrapMasterKey( + masterPrivateKey: string, + recipientPublicKey: string, +): Promise { + return encrypt(Buffer.from(masterPrivateKey, 'utf-8'), [recipientPublicKey]); +} + +/** + * Unwrap (decrypt) a master key's private key using an identity string. + * If the identity starts with "passphrase:", it was created via + * passphraseToIdentity and we use passphrase-based decryption. + */ +export async function unwrapMasterKey( + wrappedKey: Buffer, + identity: string, +): Promise { + try { + // If this is a passphrase-derived identity (from passphraseToIdentity), + // use passphrase-based decryption. + if (identity.includes(':')) { + const plaintext = await decryptWithPassphrase(wrappedKey, identity); + return plaintext.toString('utf-8'); + } + // Otherwise it's a native age identity (AGE-SECRET-KEY-1...) + const plaintext = await decrypt(wrappedKey, identity); + return plaintext.toString('utf-8'); + } catch (err) { + if (err instanceof InvalidKeyError) throw err; + throw new InvalidKeyError( + err instanceof Error ? err.message : 'Failed to unwrap master key', + ); + } +} + +/** + * Wrap a master key's private key for a passphrase-derived identity. + */ +export async function wrapMasterKeyWithPassphrase( + masterPrivateKey: string, + passphraseIdentity: string, +): Promise { + return encryptWithPassphrase( + Buffer.from(masterPrivateKey, 'utf-8'), + passphraseIdentity, + ); +} diff --git a/src/security/index.ts b/src/security/index.ts index cb0ff5c..ddb38fc 100644 --- a/src/security/index.ts +++ b/src/security/index.ts @@ -1 +1,3 @@ -export {}; +export { validateVaultPath, hasAlternateDataStream } from './paths.js'; +export { sanitizeOutput, sanitizeSearchPattern } from './sanitize.js'; +export { setRestrictivePermissions } from './permissions.js'; diff --git a/src/security/paths.ts b/src/security/paths.ts new file mode 100644 index 0000000..dc6a811 --- /dev/null +++ b/src/security/paths.ts @@ -0,0 +1,95 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import { PathTraversalError } from '../types.js'; + +/** Windows reserved device names */ +const WINDOWS_RESERVED = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i; + +/** + * Validate and resolve a vault path, ensuring it stays within the vault root. + * + * @param userPath - The user-provided path (relative to vault root) + * @param vaultRoot - Absolute path to the vault root directory + * @returns The resolved absolute path guaranteed to be within vaultRoot + * @throws PathTraversalError if path escapes vault root + */ +export function validateVaultPath( + userPath: string, + vaultRoot: string, +): string { + // 1. Reject null bytes + if (userPath.includes('\0')) { + throw new PathTraversalError('Path contains null bytes'); + } + + // 2. Normalize the vault root to a resolved absolute path + const resolvedRoot = path.resolve(vaultRoot); + + // 3. Join vaultRoot + userPath, resolve to absolute + const resolved = path.resolve(resolvedRoot, userPath); + + // 4. Ensure the resolved path is within the vault root + const rootWithSep = resolvedRoot + path.sep; + const isWithin = + process.platform === 'win32' + ? resolved.toLowerCase() === resolvedRoot.toLowerCase() || + resolved.toLowerCase().startsWith(rootWithSep.toLowerCase()) + : resolved === resolvedRoot || resolved.startsWith(rootWithSep); + + if (!isWithin) { + throw new PathTraversalError( + `Path "${userPath}" resolves outside vault root`, + ); + } + + // 5. Windows-specific checks + if (process.platform === 'win32') { + // Check every component of the relative path for ADS and reserved names + const relative = path.relative(resolvedRoot, resolved); + const components = relative.split(path.sep); + + for (const component of components) { + if (component === '') continue; + + if (hasAlternateDataStream(component)) { + throw new PathTraversalError( + `Path contains Windows Alternate Data Stream notation: "${component}"`, + ); + } + + // Strip extension for reserved-name check (e.g. CON.txt is also reserved) + if (WINDOWS_RESERVED.test(component)) { + throw new PathTraversalError( + `Path contains Windows reserved device name: "${component}"`, + ); + } + } + } + + // 6. Symlink detection — reject if path exists and is a symlink + try { + const stat = fs.lstatSync(resolved); + if (stat.isSymbolicLink()) { + throw new PathTraversalError( + `Path "${userPath}" is a symbolic link, which is not allowed`, + ); + } + } catch (err) { + // If the file doesn't exist, that's fine (new file path) + if (err instanceof PathTraversalError) throw err; + // ENOENT or other fs errors are acceptable + } + + return resolved; +} + +/** + * Check if a filename contains Windows Alternate Data Stream notation. + * Looks for colon in the basename (ignoring drive letter prefix). + */ +export function hasAlternateDataStream(filepath: string): boolean { + const basename = path.basename(filepath); + // On Windows, a drive-letter path like C: would have a colon at index 1, + // but basenames should never contain drive letters. + return basename.includes(':'); +} diff --git a/src/security/permissions.ts b/src/security/permissions.ts new file mode 100644 index 0000000..5b2cb83 --- /dev/null +++ b/src/security/permissions.ts @@ -0,0 +1,18 @@ +import fs from 'node:fs'; + +/** + * Set restrictive permissions on a file or directory. + * POSIX: 0o700 for dirs, 0o600 for files + * Windows: best-effort (fs.chmod may not work the same way) + */ +export function setRestrictivePermissions( + filePath: string, + type: 'file' | 'directory', +): void { + const mode = type === 'directory' ? 0o700 : 0o600; + try { + fs.chmodSync(filePath, mode); + } catch { + // On Windows, chmod may not work — this is best-effort + } +} diff --git a/src/security/sanitize.ts b/src/security/sanitize.ts new file mode 100644 index 0000000..fe836f0 --- /dev/null +++ b/src/security/sanitize.ts @@ -0,0 +1,46 @@ +/** + * Regex matching ANSI escape sequences: + * - CSI sequences: ESC [ ... final byte + * - OSC sequences: ESC ] ... (terminated by BEL or ST) + * - Other escape sequences: ESC followed by a character + */ +const ANSI_REGEX = + // eslint-disable-next-line no-control-regex, no-useless-escape + /[\u001b\u009b][\[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><~]|[\u001b\u009b]\].*?(?:\u0007|\u001b\\)|[\u001b\u009b][^[\]]{0,2}/g; + +/** + * Regex matching control characters except \n (0x0A), \r (0x0D), and \t (0x09). + */ +const CONTROL_CHARS_REGEX = + // eslint-disable-next-line no-control-regex + /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g; + +/** + * Strip ANSI escape sequences and control characters from a string. + * Use this before printing decrypted content to terminal. + * Preserves newlines (\n), carriage returns (\r), and tabs (\t). + */ +export function sanitizeOutput(input: string): string { + return input.replace(ANSI_REGEX, '').replace(CONTROL_CHARS_REGEX, ''); +} + +/** Maximum allowed length for search patterns */ +const MAX_PATTERN_LENGTH = 1024; + +/** + * Validate a search pattern to prevent injection. + * Allows normal regex patterns but rejects attempts to escape context. + */ +export function sanitizeSearchPattern(pattern: string): string { + if (pattern.includes('\0')) { + throw new Error('Search pattern must not contain null bytes'); + } + + if (pattern.length > MAX_PATTERN_LENGTH) { + throw new Error( + `Search pattern exceeds maximum length of ${MAX_PATTERN_LENGTH}`, + ); + } + + return pattern; +} diff --git a/test/unit/audit.test.ts b/test/unit/audit.test.ts new file mode 100644 index 0000000..0113326 --- /dev/null +++ b/test/unit/audit.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { AuditLogger } from '../../src/audit/index.js'; +import { OperationType } from '../../src/types.js'; + +describe('AuditLogger', () => { + let logger: AuditLogger; + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'vault-audit-')); + logger = new AuditLogger(join(tempDir, 'audit.db')); + }); + + afterEach(() => { + logger.close(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('creates database and table on construction', () => { + // If we got here without throwing, the DB and table were created. + // Verify we can query with zero results. + expect(logger.getEvents()).toEqual([]); + expect(logger.getEventCount()).toBe(0); + }); + + it('logs a read event', () => { + logger.logEvent({ operation: OperationType.READ, targetPath: 'docs/letter.pdf' }); + const events = logger.getEvents({ limit: 1 }); + expect(events).toHaveLength(1); + expect(events[0].operation).toBe(OperationType.READ); + expect(events[0].targetPath).toBe('docs/letter.pdf'); + expect(events[0].success).toBe(true); + }); + + it('logs an unlock event with keyId', () => { + logger.logEvent({ + operation: OperationType.UNLOCK, + keyId: 'key-abc-123', + }); + const events = logger.getEvents({ limit: 1 }); + expect(events).toHaveLength(1); + expect(events[0].operation).toBe(OperationType.UNLOCK); + expect(events[0].keyId).toBe('key-abc-123'); + expect(events[0].targetPath).toBeUndefined(); + expect(events[0].success).toBe(true); + }); + + it('logs a failed operation', () => { + logger.logEvent({ operation: OperationType.READ, targetPath: 'secret.txt', success: false }); + const events = logger.getEvents(); + expect(events[0].success).toBe(false); + }); + + it('returns events in reverse chronological order', () => { + logger.logEvent({ operation: OperationType.READ, targetPath: 'a.txt' }); + logger.logEvent({ operation: OperationType.WRITE, targetPath: 'b.txt' }); + logger.logEvent({ operation: OperationType.DELETE, targetPath: 'c.txt' }); + + const events = logger.getEvents(); + expect(events).toHaveLength(3); + // Most recent first + expect(events[0].operation).toBe(OperationType.DELETE); + expect(events[1].operation).toBe(OperationType.WRITE); + expect(events[2].operation).toBe(OperationType.READ); + }); + + it('filters by operation type', () => { + logger.logEvent({ operation: OperationType.READ, targetPath: 'a.txt' }); + logger.logEvent({ operation: OperationType.WRITE, targetPath: 'b.txt' }); + logger.logEvent({ operation: OperationType.READ, targetPath: 'c.txt' }); + + const reads = logger.getEvents({ operation: OperationType.READ }); + expect(reads).toHaveLength(2); + for (const e of reads) { + expect(e.operation).toBe(OperationType.READ); + } + }); + + it('filters by date (since)', () => { + logger.logEvent({ operation: OperationType.READ, targetPath: 'old.txt' }); + + // Use a timestamp slightly in the future to filter out the first event + const cutoff = new Date(Date.now() + 1000); + + // Wait a tiny bit then log another event with a future-enough timestamp + logger.logEvent({ operation: OperationType.WRITE, targetPath: 'new.txt' }); + + // Since both events are very close in time, use getEvents to verify + // the since filter works by using a far-past date + const allEvents = logger.getEvents({ since: new Date('2000-01-01') }); + expect(allEvents.length).toBeGreaterThanOrEqual(2); + + // Use a future cutoff to get zero events + const futureEvents = logger.getEvents({ since: new Date(Date.now() + 60_000) }); + expect(futureEvents).toHaveLength(0); + }); + + it('limits results', () => { + for (let i = 0; i < 10; i++) { + logger.logEvent({ operation: OperationType.READ, targetPath: `file${i}.txt` }); + } + const limited = logger.getEvents({ limit: 3 }); + expect(limited).toHaveLength(3); + }); + + it('counts events correctly', () => { + expect(logger.getEventCount()).toBe(0); + logger.logEvent({ operation: OperationType.READ }); + logger.logEvent({ operation: OperationType.WRITE }); + logger.logEvent({ operation: OperationType.DELETE }); + expect(logger.getEventCount()).toBe(3); + }); + + it('handles all operation types', () => { + for (const op of Object.values(OperationType)) { + logger.logEvent({ operation: op }); + } + expect(logger.getEventCount()).toBe(Object.values(OperationType).length); + + const events = logger.getEvents(); + const ops = new Set(events.map((e) => e.operation)); + for (const op of Object.values(OperationType)) { + expect(ops.has(op)).toBe(true); + } + }); + + it('NEVER stores content — only operation metadata', async () => { + // Open a raw database connection to inspect the schema + const Database = (await import('better-sqlite3')).default; + const db = new Database(join(tempDir, 'audit.db'), { readonly: true }); + const columns = db.prepare("PRAGMA table_info('audit_log')").all() as Array<{ name: string }>; + const columnNames = columns.map((c) => c.name); + + // Only these columns should exist + expect(columnNames).toEqual( + expect.arrayContaining(['id', 'timestamp', 'operation', 'target_path', 'key_id', 'success']), + ); + expect(columnNames).toHaveLength(6); + + // Explicitly verify no content-like column exists + expect(columnNames).not.toContain('content'); + expect(columnNames).not.toContain('data'); + expect(columnNames).not.toContain('body'); + expect(columnNames).not.toContain('payload'); + + db.close(); + }); + + it('handles concurrent access gracefully (WAL mode)', () => { + const dbPath = join(tempDir, 'audit.db'); + const logger2 = new AuditLogger(dbPath); + + try { + // Write from both loggers + logger.logEvent({ operation: OperationType.READ, targetPath: 'from-logger1.txt' }); + logger2.logEvent({ operation: OperationType.WRITE, targetPath: 'from-logger2.txt' }); + + // Read from both and verify no data corruption + const events1 = logger.getEvents(); + const events2 = logger2.getEvents(); + + expect(events1).toHaveLength(2); + expect(events2).toHaveLength(2); + + const paths1 = events1.map((e) => e.targetPath).sort(); + const paths2 = events2.map((e) => e.targetPath).sort(); + expect(paths1).toEqual(['from-logger1.txt', 'from-logger2.txt']); + expect(paths2).toEqual(['from-logger1.txt', 'from-logger2.txt']); + } finally { + logger2.close(); + } + }); + + it('assigns sequential ids to events', () => { + logger.logEvent({ operation: OperationType.READ }); + logger.logEvent({ operation: OperationType.WRITE }); + const events = logger.getEvents(); + expect(events[0].id).toBeGreaterThan(events[1].id); + }); + + it('stores valid ISO 8601 timestamps', () => { + logger.logEvent({ operation: OperationType.INIT }); + const events = logger.getEvents({ limit: 1 }); + expect(events[0].timestamp).toBeInstanceOf(Date); + expect(events[0].timestamp.toISOString()).toBeTruthy(); + }); +}); diff --git a/test/unit/crypto.test.ts b/test/unit/crypto.test.ts new file mode 100644 index 0000000..0c0a7b8 --- /dev/null +++ b/test/unit/crypto.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from 'vitest'; +import { + encrypt, + decrypt, + encryptWithPassphrase, + decryptWithPassphrase, + generateKeyPair, + passphraseToIdentity, + generateSalt, + wrapMasterKey, + unwrapMasterKey, + wrapMasterKeyWithPassphrase, +} from '../../src/crypto/index.js'; +import { InvalidKeyError } from '../../src/types.js'; + +describe('crypto/age', () => { + it('encrypts and decrypts a buffer round-trip', async () => { + const { publicKey, privateKey } = await generateKeyPair(); + const plaintext = Buffer.from('hello, age encryption!'); + const ciphertext = await encrypt(plaintext, [publicKey]); + const decrypted = await decrypt(ciphertext, privateKey); + expect(decrypted).toEqual(plaintext); + }); + + it('decryption with wrong key throws InvalidKeyError', async () => { + const { publicKey } = await generateKeyPair(); + const { privateKey: wrongKey } = await generateKeyPair(); + const plaintext = Buffer.from('secret data'); + const ciphertext = await encrypt(plaintext, [publicKey]); + await expect(decrypt(ciphertext, wrongKey)).rejects.toThrow(InvalidKeyError); + }); + + it('handles empty buffer', async () => { + const { publicKey, privateKey } = await generateKeyPair(); + const plaintext = Buffer.alloc(0); + const ciphertext = await encrypt(plaintext, [publicKey]); + const decrypted = await decrypt(ciphertext, privateKey); + expect(decrypted).toEqual(plaintext); + }); + + it('handles large buffer (1MB)', async () => { + const { publicKey, privateKey } = await generateKeyPair(); + const plaintext = Buffer.alloc(1024 * 1024, 0xab); + const ciphertext = await encrypt(plaintext, [publicKey]); + const decrypted = await decrypt(ciphertext, privateKey); + expect(decrypted).toEqual(plaintext); + }); + + it('supports multiple recipients', async () => { + const kp1 = await generateKeyPair(); + const kp2 = await generateKeyPair(); + const plaintext = Buffer.from('shared secret'); + const ciphertext = await encrypt(plaintext, [kp1.publicKey, kp2.publicKey]); + + const decrypted1 = await decrypt(ciphertext, kp1.privateKey); + expect(decrypted1).toEqual(plaintext); + + const decrypted2 = await decrypt(ciphertext, kp2.privateKey); + expect(decrypted2).toEqual(plaintext); + }); +}); + +describe('crypto/age passphrase', () => { + it('encrypts and decrypts with passphrase round-trip', async () => { + const plaintext = Buffer.from('passphrase-protected data'); + const passphrase = 'my-strong-passphrase-123'; + const ciphertext = await encryptWithPassphrase(plaintext, passphrase); + const decrypted = await decryptWithPassphrase(ciphertext, passphrase); + expect(decrypted).toEqual(plaintext); + }); + + it('decryption with wrong passphrase throws InvalidKeyError', async () => { + const plaintext = Buffer.from('secret'); + const ciphertext = await encryptWithPassphrase(plaintext, 'correct-pass'); + await expect( + decryptWithPassphrase(ciphertext, 'wrong-pass'), + ).rejects.toThrow(InvalidKeyError); + }); +}); + +describe('crypto/keys', () => { + it('generates a valid age keypair', async () => { + const { publicKey, privateKey } = await generateKeyPair(); + expect(publicKey).toMatch(/^age1/); + expect(privateKey).toMatch(/^AGE-SECRET-KEY-1/); + }); + + it('passphraseToIdentity is deterministic for same passphrase+salt', async () => { + const salt = generateSalt(); + const a = await passphraseToIdentity('test-passphrase', salt); + const b = await passphraseToIdentity('test-passphrase', salt); + expect(a.identity).toBe(b.identity); + expect(a.publicKey).toBe(b.publicKey); + }); + + it('passphraseToIdentity differs for different passphrases', async () => { + const salt = generateSalt(); + const a = await passphraseToIdentity('passphrase-one', salt); + const b = await passphraseToIdentity('passphrase-two', salt); + expect(a.identity).not.toBe(b.identity); + expect(a.publicKey).not.toBe(b.publicKey); + }); + + it('passphraseToIdentity differs for different salts', async () => { + const a = await passphraseToIdentity('same-pass', generateSalt()); + const b = await passphraseToIdentity('same-pass', generateSalt()); + expect(a.identity).not.toBe(b.identity); + expect(a.publicKey).not.toBe(b.publicKey); + }); + + it('generateSalt returns unique values', () => { + const salts = new Set(Array.from({ length: 10 }, () => generateSalt())); + expect(salts.size).toBe(10); + }); + + it('generateSalt returns 64 hex characters (32 bytes)', () => { + const salt = generateSalt(); + expect(salt).toMatch(/^[0-9a-f]{64}$/); + }); + + it('wrapMasterKey + unwrapMasterKey round-trip works', async () => { + const masterKp = await generateKeyPair(); + const recipientKp = await generateKeyPair(); + + const wrapped = await wrapMasterKey(masterKp.privateKey, recipientKp.publicKey); + const unwrapped = await unwrapMasterKey(wrapped, recipientKp.privateKey); + expect(unwrapped).toBe(masterKp.privateKey); + }); + + it('unwrapMasterKey with wrong identity throws InvalidKeyError', async () => { + const masterKp = await generateKeyPair(); + const recipientKp = await generateKeyPair(); + const wrongKp = await generateKeyPair(); + + const wrapped = await wrapMasterKey(masterKp.privateKey, recipientKp.publicKey); + await expect(unwrapMasterKey(wrapped, wrongKp.privateKey)).rejects.toThrow( + InvalidKeyError, + ); + }); + + it('wrapMasterKeyWithPassphrase + unwrapMasterKey round-trip works', async () => { + const masterKp = await generateKeyPair(); + const salt = generateSalt(); + const { identity } = await passphraseToIdentity('my-vault-passphrase', salt); + + const wrapped = await wrapMasterKeyWithPassphrase(masterKp.privateKey, identity); + const unwrapped = await unwrapMasterKey(wrapped, identity); + expect(unwrapped).toBe(masterKp.privateKey); + }); + + it('unwrapMasterKey with wrong passphrase identity throws InvalidKeyError', async () => { + const masterKp = await generateKeyPair(); + const salt = generateSalt(); + const { identity } = await passphraseToIdentity('correct-passphrase', salt); + const { identity: wrongIdentity } = await passphraseToIdentity('wrong-passphrase', salt); + + const wrapped = await wrapMasterKeyWithPassphrase(masterKp.privateKey, identity); + await expect(unwrapMasterKey(wrapped, wrongIdentity)).rejects.toThrow( + InvalidKeyError, + ); + }); +}); diff --git a/test/unit/security.test.ts b/test/unit/security.test.ts new file mode 100644 index 0000000..122cb59 --- /dev/null +++ b/test/unit/security.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + symlinkSync, + rmSync, + statSync, + chmodSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { + validateVaultPath, + hasAlternateDataStream, +} from '../../src/security/paths.js'; +import { + sanitizeOutput, + sanitizeSearchPattern, +} from '../../src/security/sanitize.js'; +import { setRestrictivePermissions } from '../../src/security/permissions.js'; +import { PathTraversalError } from '../../src/types.js'; + +// ── Path validation ────────────────────────────────────────────────────────── + +describe('security/paths', () => { + let vaultRoot: string; + + beforeEach(() => { + vaultRoot = mkdtempSync(join(tmpdir(), 'vault-test-')); + }); + + afterEach(() => { + rmSync(vaultRoot, { recursive: true, force: true }); + }); + + it('accepts a simple relative path', () => { + const result = validateVaultPath('secret.txt', vaultRoot); + expect(result).toBe(join(vaultRoot, 'secret.txt')); + }); + + it('accepts nested paths', () => { + const result = validateVaultPath('a/b/c.txt', vaultRoot); + expect(result).toBe(join(vaultRoot, 'a', 'b', 'c.txt')); + }); + + it('rejects path with ../ traversal', () => { + expect(() => validateVaultPath('../outside.txt', vaultRoot)).toThrow( + PathTraversalError, + ); + }); + + it('rejects deeply nested ../ traversal', () => { + expect(() => + validateVaultPath('a/b/../../../../etc/passwd', vaultRoot), + ).toThrow(PathTraversalError); + }); + + it('rejects absolute path outside vault', () => { + const outsidePath = + process.platform === 'win32' ? 'C:\\Windows\\System32' : '/etc/passwd'; + expect(() => validateVaultPath(outsidePath, vaultRoot)).toThrow( + PathTraversalError, + ); + }); + + it('rejects null bytes in path', () => { + expect(() => validateVaultPath('file\0.txt', vaultRoot)).toThrow( + PathTraversalError, + ); + }); + + it('rejects symlinks pointing outside vault', () => { + const linkPath = join(vaultRoot, 'evil-link'); + try { + symlinkSync(tmpdir(), linkPath); + } catch { + // Symlink creation may require elevated privileges on Windows + return; + } + expect(() => validateVaultPath('evil-link', vaultRoot)).toThrow( + PathTraversalError, + ); + }); + + it('handles path with trailing slashes', () => { + const result = validateVaultPath('subdir/', vaultRoot); + // path.resolve strips trailing separators + expect(result).toBe(join(vaultRoot, 'subdir')); + }); + + it('handles deeply nested path', () => { + const deep = 'a/b/c/d/e/f/g/h/i/j/file.txt'; + const result = validateVaultPath(deep, vaultRoot); + expect(result).toBe(join(vaultRoot, ...deep.split('/'))); + }); + + it('handles empty path component', () => { + // Double slashes in path: "a//b.txt" — path.resolve normalizes this + const result = validateVaultPath('a//b.txt', vaultRoot); + expect(result).toBe(join(vaultRoot, 'a', 'b.txt')); + }); + + it('allows path to non-existent file (new file)', () => { + const result = validateVaultPath('new-file.txt', vaultRoot); + expect(result).toBe(join(vaultRoot, 'new-file.txt')); + }); + + it('allows path to existing regular file', () => { + writeFileSync(join(vaultRoot, 'exists.txt'), 'hello'); + const result = validateVaultPath('exists.txt', vaultRoot); + expect(result).toBe(join(vaultRoot, 'exists.txt')); + }); + + // Windows-specific tests + const isWindows = process.platform === 'win32'; + + it.skipIf(!isWindows)( + 'rejects Windows ADS (alternate data streams)', + () => { + expect(() => validateVaultPath('file.txt:Zone.Identifier', vaultRoot)).toThrow( + PathTraversalError, + ); + }, + ); + + it.skipIf(!isWindows)('rejects Windows reserved device names', () => { + for (const name of ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'LPT1']) { + expect(() => validateVaultPath(name, vaultRoot)).toThrow( + PathTraversalError, + ); + } + }); + + it.skipIf(!isWindows)( + 'rejects Windows reserved names with extension', + () => { + expect(() => validateVaultPath('CON.txt', vaultRoot)).toThrow( + PathTraversalError, + ); + }, + ); +}); + +describe('hasAlternateDataStream', () => { + it('detects ADS notation', () => { + expect(hasAlternateDataStream('file.txt:Zone.Identifier')).toBe(true); + }); + + it('returns false for normal file', () => { + expect(hasAlternateDataStream('file.txt')).toBe(false); + }); + + it('returns false for path with directory separators only', () => { + expect(hasAlternateDataStream('a/b/c.txt')).toBe(false); + }); +}); + +// ── Output sanitization ────────────────────────────────────────────────────── + +describe('security/sanitize', () => { + it('strips ANSI color codes', () => { + expect(sanitizeOutput('\x1b[31mred\x1b[0m')).toBe('red'); + }); + + it('strips ANSI cursor movement', () => { + expect(sanitizeOutput('\x1b[2Ahello')).toBe('hello'); + }); + + it('strips OSC sequences', () => { + expect(sanitizeOutput('\x1b]0;title\x07text')).toBe('text'); + }); + + it('preserves newlines and tabs', () => { + expect(sanitizeOutput('line1\nline2\ttab')).toBe('line1\nline2\ttab'); + }); + + it('strips null bytes', () => { + expect(sanitizeOutput('hello\x00world')).toBe('helloworld'); + }); + + it('strips other control characters', () => { + expect(sanitizeOutput('hello\x01\x02\x03world')).toBe('helloworld'); + }); + + it('strips DEL character (0x7F)', () => { + expect(sanitizeOutput('hello\x7Fworld')).toBe('helloworld'); + }); + + it('handles empty string', () => { + expect(sanitizeOutput('')).toBe(''); + }); + + it('passes through clean text unchanged', () => { + const clean = 'Hello, World! 123 @#$%'; + expect(sanitizeOutput(clean)).toBe(clean); + }); + + it('preserves carriage returns', () => { + expect(sanitizeOutput('line1\r\nline2')).toBe('line1\r\nline2'); + }); +}); + +describe('sanitizeSearchPattern', () => { + it('accepts a normal regex pattern', () => { + expect(sanitizeSearchPattern('[a-z]+')).toBe('[a-z]+'); + }); + + it('rejects null bytes', () => { + expect(() => sanitizeSearchPattern('test\0')).toThrow(/null bytes/); + }); + + it('rejects extremely long patterns', () => { + expect(() => sanitizeSearchPattern('a'.repeat(2000))).toThrow( + /maximum length/, + ); + }); + + it('accepts pattern at max length', () => { + const pattern = 'a'.repeat(1024); + expect(sanitizeSearchPattern(pattern)).toBe(pattern); + }); +}); + +// ── File permissions ───────────────────────────────────────────────────────── + +describe('security/permissions', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'vault-perm-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + const isPosix = process.platform !== 'win32'; + + it.skipIf(!isPosix)('sets file permissions to 0o600 on POSIX', () => { + const filePath = join(tempDir, 'secret.txt'); + writeFileSync(filePath, 'secret'); + setRestrictivePermissions(filePath, 'file'); + const mode = statSync(filePath).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it.skipIf(!isPosix)('sets directory permissions to 0o700 on POSIX', () => { + const dirPath = join(tempDir, 'secret-dir'); + mkdirSync(dirPath); + setRestrictivePermissions(dirPath, 'directory'); + const mode = statSync(dirPath).mode & 0o777; + expect(mode).toBe(0o700); + }); + + it('does not throw on any platform', () => { + const filePath = join(tempDir, 'test.txt'); + writeFileSync(filePath, 'test'); + expect(() => setRestrictivePermissions(filePath, 'file')).not.toThrow(); + }); + + it('does not throw for directory on any platform', () => { + const dirPath = join(tempDir, 'test-dir'); + mkdirSync(dirPath); + expect(() => + setRestrictivePermissions(dirPath, 'directory'), + ).not.toThrow(); + }); +}); From 1766286b4953539aab60e6d474279aad84a49b52 Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 11:36:00 -0700 Subject: [PATCH 07/23] config: configuration management with platform-aware paths - ConfigManager class with load/save/initialize - Platform-aware config dir (XDG, AppData, Library) - Restrictive file permissions on config - 10 unit tests covering initialization, round-trip, overwrite Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/config/index.ts | 92 ++++++++++++++++++++++++++++++++++++- test/unit/config.test.ts | 98 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 test/unit/config.test.ts diff --git a/src/config/index.ts b/src/config/index.ts index cb0ff5c..9855154 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1 +1,91 @@ -export {}; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { type VaultConfig } from '../types.js'; +import { setRestrictivePermissions } from '../security/index.js'; + +const CONFIG_VERSION = 1; +const CONFIG_DIR_NAME = 'workspace-vault'; +const CONFIG_FILE_NAME = 'config.json'; +const SESSION_FILE_NAME = 'session'; + +function getConfigDir(): string { + if (process.platform === 'win32') { + return path.join(process.env['APPDATA'] || path.join(os.homedir(), 'AppData', 'Roaming'), CONFIG_DIR_NAME); + } + if (process.platform === 'darwin') { + return path.join(os.homedir(), 'Library', 'Application Support', CONFIG_DIR_NAME); + } + // Linux / other: respect XDG_CONFIG_HOME + const xdg = process.env['XDG_CONFIG_HOME'] || path.join(os.homedir(), '.config'); + return path.join(xdg, CONFIG_DIR_NAME); +} + +export class ConfigManager { + private readonly configDir: string; + private readonly configPath: string; + + constructor(configDir?: string) { + this.configDir = configDir ?? getConfigDir(); + this.configPath = path.join(this.configDir, CONFIG_FILE_NAME); + } + + getConfigDir(): string { + return this.configDir; + } + + getConfigPath(): string { + return this.configPath; + } + + getDefaultSessionPath(): string { + return path.join(this.configDir, SESSION_FILE_NAME); + } + + isInitialized(): boolean { + return fs.existsSync(this.configPath); + } + + load(): VaultConfig { + if (!this.isInitialized()) { + throw new Error('Vault not initialized. Run `vault init` first.'); + } + const raw = fs.readFileSync(this.configPath, 'utf-8'); + return JSON.parse(raw) as VaultConfig; + } + + save(config: VaultConfig): void { + this.ensureConfigDir(); + fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf-8'); + setRestrictivePermissions(this.configPath, 'file'); + } + + getVaultPath(): string { + return this.load().vaultPath; + } + + getSessionPath(): string { + return this.load().sessionPath; + } + + private ensureConfigDir(): void { + if (!fs.existsSync(this.configDir)) { + fs.mkdirSync(this.configDir, { recursive: true }); + setRestrictivePermissions(this.configDir, 'directory'); + } + } + + /** + * Create initial config for a new vault. + */ + initialize(vaultPath: string): VaultConfig { + const config: VaultConfig = { + vaultPath: path.resolve(vaultPath), + sessionPath: this.getDefaultSessionPath(), + version: CONFIG_VERSION, + }; + this.save(config); + return config; + } +} + diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts new file mode 100644 index 0000000..d64c5b0 --- /dev/null +++ b/test/unit/config.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { ConfigManager } from '../../src/config/index.js'; + +describe('ConfigManager', () => { + let tempDir: string; + let configDir: string; + let manager: ConfigManager; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'vault-config-')); + configDir = join(tempDir, 'config'); + manager = new ConfigManager(configDir); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('reports not initialized when no config exists', () => { + expect(manager.isInitialized()).toBe(false); + }); + + it('throws when loading before initialization', () => { + expect(() => manager.load()).toThrow('Vault not initialized'); + }); + + it('initializes config and creates config file', () => { + const vaultPath = join(tempDir, 'vault'); + const config = manager.initialize(vaultPath); + + expect(manager.isInitialized()).toBe(true); + expect(config.vaultPath).toBe(vaultPath); + expect(config.sessionPath).toBe(manager.getDefaultSessionPath()); + expect(config.version).toBe(1); + }); + + it('saves and loads config round-trip', () => { + const vaultPath = join(tempDir, 'vault'); + manager.initialize(vaultPath); + + const loaded = manager.load(); + expect(loaded.vaultPath).toBe(vaultPath); + expect(loaded.version).toBe(1); + }); + + it('getVaultPath returns the vault path', () => { + const vaultPath = join(tempDir, 'vault'); + manager.initialize(vaultPath); + expect(manager.getVaultPath()).toBe(vaultPath); + }); + + it('getSessionPath returns the session file path', () => { + const vaultPath = join(tempDir, 'vault'); + manager.initialize(vaultPath); + expect(manager.getSessionPath()).toBe(manager.getDefaultSessionPath()); + }); + + it('creates config directory with correct structure', () => { + const vaultPath = join(tempDir, 'vault'); + manager.initialize(vaultPath); + + expect(existsSync(configDir)).toBe(true); + expect(existsSync(manager.getConfigPath())).toBe(true); + }); + + it('config file contains valid JSON', () => { + const vaultPath = join(tempDir, 'vault'); + manager.initialize(vaultPath); + + const raw = readFileSync(manager.getConfigPath(), 'utf-8'); + const parsed = JSON.parse(raw); + expect(parsed).toHaveProperty('vaultPath'); + expect(parsed).toHaveProperty('sessionPath'); + expect(parsed).toHaveProperty('version'); + }); + + it('resolves relative vault path to absolute', () => { + manager.initialize('relative/vault/path'); + const loaded = manager.load(); + expect(loaded.vaultPath).toMatch(/^[A-Z]:|^\//i); // starts with drive letter or / + }); + + it('can overwrite config with save', () => { + const vaultPath1 = join(tempDir, 'vault1'); + const vaultPath2 = join(tempDir, 'vault2'); + + manager.initialize(vaultPath1); + expect(manager.getVaultPath()).toBe(vaultPath1); + + const config = manager.load(); + config.vaultPath = vaultPath2; + manager.save(config); + expect(manager.getVaultPath()).toBe(vaultPath2); + }); +}); From 966f8faa19d126a485794e1127b98e36799130bb Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 11:42:27 -0700 Subject: [PATCH 08/23] session: session file management for unlock state - SessionManager: write/read/clear session file with TTL - Atomic writes via temp file + rename - Best-effort zero-fill on session clear - Expiry checking with automatic cleanup - Session info query (without exposing master key) - Comprehensive unit tests for full lifecycle Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/session/index.ts | 2 +- src/session/manager.ts | 122 +++++++++++++++++++++++++++++++++++ test/unit/session.test.ts | 129 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 src/session/manager.ts create mode 100644 test/unit/session.test.ts diff --git a/src/session/index.ts b/src/session/index.ts index cb0ff5c..1cc707c 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -1 +1 @@ -export {}; +export { SessionManager } from './manager.js'; diff --git a/src/session/manager.ts b/src/session/manager.ts new file mode 100644 index 0000000..3bd1228 --- /dev/null +++ b/src/session/manager.ts @@ -0,0 +1,122 @@ +import fs from 'node:fs'; +import { type SessionData, VaultLockedError, SessionExpiredError, SessionFileError } from '../types.js'; +import { setRestrictivePermissions } from '../security/index.js'; + +const DEFAULT_TTL_MINUTES = 30; + +export class SessionManager { + private readonly sessionPath: string; + + constructor(sessionPath: string) { + this.sessionPath = sessionPath; + } + + /** + * Write a new session file with the master key and TTL. + * Sets restrictive file permissions (0600). + */ + writeSession(masterKey: string, ttlMinutes: number = DEFAULT_TTL_MINUTES): void { + const now = new Date(); + const expiresAt = new Date(now.getTime() + ttlMinutes * 60 * 1000); + + const data: SessionData = { + masterKey, + expiresAt: expiresAt.toISOString(), + createdAt: now.toISOString(), + }; + + // Atomic write: write to temp file then rename + const tempPath = `${this.sessionPath}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(data), 'utf-8'); + setRestrictivePermissions(tempPath, 'file'); + fs.renameSync(tempPath, this.sessionPath); + } + + /** + * Read the session file and return the master key. + * Throws VaultLockedError if no session file exists. + * Throws SessionExpiredError if the session has expired (and cleans up). + */ + readSession(): string { + if (!fs.existsSync(this.sessionPath)) { + throw new VaultLockedError('Vault is locked. Run `vault unlock` to unlock.'); + } + + let data: SessionData; + try { + const raw = fs.readFileSync(this.sessionPath, 'utf-8'); + data = JSON.parse(raw) as SessionData; + } catch (err) { + throw new SessionFileError(`Failed to read session file: ${(err as Error).message}`); + } + + // Check TTL + const expiresAt = new Date(data.expiresAt); + if (expiresAt <= new Date()) { + this.clearSession(); + throw new SessionExpiredError(); + } + + return data.masterKey; + } + + /** + * Clear the session file (lock the vault). + * Best-effort: overwrite with zeros before deleting. + */ + clearSession(): void { + if (!fs.existsSync(this.sessionPath)) return; + + try { + // Best-effort zero-fill before delete + const stat = fs.statSync(this.sessionPath); + const zeros = Buffer.alloc(stat.size, 0); + fs.writeFileSync(this.sessionPath, zeros); + } catch { + // Ignore — best effort + } + + try { + fs.unlinkSync(this.sessionPath); + } catch { + // Ignore — file may already be gone + } + } + + /** + * Check if a valid (non-expired) session exists. + */ + isUnlocked(): boolean { + try { + this.readSession(); + return true; + } catch { + return false; + } + } + + /** + * Get session info without the master key (for status display). + */ + getSessionInfo(): { expiresAt: Date; createdAt: Date } | null { + if (!fs.existsSync(this.sessionPath)) return null; + + try { + const raw = fs.readFileSync(this.sessionPath, 'utf-8'); + const data = JSON.parse(raw) as SessionData; + const expiresAt = new Date(data.expiresAt); + + if (expiresAt <= new Date()) { + this.clearSession(); + return null; + } + + return { + expiresAt, + createdAt: new Date(data.createdAt), + }; + } catch { + return null; + } + } +} diff --git a/test/unit/session.test.ts b/test/unit/session.test.ts new file mode 100644 index 0000000..3af2650 --- /dev/null +++ b/test/unit/session.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { SessionManager } from '../../src/session/index.js'; +import { VaultLockedError, SessionExpiredError } from '../../src/types.js'; + +describe('SessionManager', () => { + let tempDir: string; + let sessionPath: string; + let manager: SessionManager; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'vault-session-')); + sessionPath = join(tempDir, 'session'); + manager = new SessionManager(sessionPath); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('writeSession', () => { + it('creates a session file', () => { + manager.writeSession('deadbeef'); + expect(existsSync(sessionPath)).toBe(true); + }); + + it('writes valid JSON with required fields', () => { + manager.writeSession('deadbeef'); + const raw = readFileSync(sessionPath, 'utf-8'); + const data = JSON.parse(raw); + expect(data).toHaveProperty('masterKey', 'deadbeef'); + expect(data).toHaveProperty('expiresAt'); + expect(data).toHaveProperty('createdAt'); + }); + + it('sets expiry based on TTL', () => { + manager.writeSession('deadbeef', 60); + const raw = readFileSync(sessionPath, 'utf-8'); + const data = JSON.parse(raw); + const expiresAt = new Date(data.expiresAt); + const now = new Date(); + // Should expire ~60 minutes from now (allow 5s tolerance) + const diffMinutes = (expiresAt.getTime() - now.getTime()) / 60000; + expect(diffMinutes).toBeGreaterThan(59); + expect(diffMinutes).toBeLessThan(61); + }); + + it('overwrites existing session file', () => { + manager.writeSession('key1'); + manager.writeSession('key2'); + const raw = readFileSync(sessionPath, 'utf-8'); + const data = JSON.parse(raw); + expect(data.masterKey).toBe('key2'); + }); + }); + + describe('readSession', () => { + it('returns master key from valid session', () => { + manager.writeSession('deadbeef'); + expect(manager.readSession()).toBe('deadbeef'); + }); + + it('throws VaultLockedError when no session file exists', () => { + expect(() => manager.readSession()).toThrow(VaultLockedError); + }); + + it('throws SessionExpiredError when session is expired', () => { + manager.writeSession('deadbeef', 0); + expect(() => manager.readSession()).toThrow(SessionExpiredError); + }); + + it('cleans up expired session file', () => { + manager.writeSession('deadbeef', 0); + try { manager.readSession(); } catch { /* expected */ } + expect(existsSync(sessionPath)).toBe(false); + }); + }); + + describe('clearSession', () => { + it('removes session file', () => { + manager.writeSession('deadbeef'); + manager.clearSession(); + expect(existsSync(sessionPath)).toBe(false); + }); + + it('does not throw when no session file exists', () => { + expect(() => manager.clearSession()).not.toThrow(); + }); + }); + + describe('isUnlocked', () => { + it('returns true when valid session exists', () => { + manager.writeSession('deadbeef'); + expect(manager.isUnlocked()).toBe(true); + }); + + it('returns false when no session exists', () => { + expect(manager.isUnlocked()).toBe(false); + }); + + it('returns false when session is expired', () => { + manager.writeSession('deadbeef', 0); + expect(manager.isUnlocked()).toBe(false); + }); + }); + + describe('getSessionInfo', () => { + it('returns session info without master key', () => { + manager.writeSession('deadbeef', 30); + const info = manager.getSessionInfo(); + expect(info).not.toBeNull(); + expect(info!.expiresAt).toBeInstanceOf(Date); + expect(info!.createdAt).toBeInstanceOf(Date); + // Should NOT contain masterKey + expect(info).not.toHaveProperty('masterKey'); + }); + + it('returns null when no session exists', () => { + expect(manager.getSessionInfo()).toBeNull(); + }); + + it('returns null for expired session', () => { + manager.writeSession('deadbeef', 0); + expect(manager.getSessionInfo()).toBeNull(); + }); + }); +}); From 9183eb1dd6014e84f786d428ff57454b8add7004 Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 13:30:52 -0700 Subject: [PATCH 09/23] vault: core engine with file CRUD, key management, and search - VaultEngine: init, writeFile, readFile, deleteFile, listFiles - MetadataStore: SQLite metadata with WAL mode - File search (filename/tag match) and content grep - Multi-key management: add, revoke, list - Unlock via passphrase-based key matching - Audit logging integrated into all operations - Comprehensive unit tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vault/engine.ts | 320 ++++++++++++++++++++++++++++ src/vault/index.ts | 3 +- src/vault/metadata.ts | 221 ++++++++++++++++++++ test/unit/vault.test.ts | 452 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 995 insertions(+), 1 deletion(-) create mode 100644 src/vault/engine.ts create mode 100644 src/vault/metadata.ts create mode 100644 test/unit/vault.test.ts diff --git a/src/vault/engine.ts b/src/vault/engine.ts new file mode 100644 index 0000000..365f327 --- /dev/null +++ b/src/vault/engine.ts @@ -0,0 +1,320 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { + encrypt, + decrypt, + generateKeyPair, + passphraseToIdentity, + generateSalt, + wrapMasterKeyWithPassphrase, + unwrapMasterKey, +} from '../crypto/index.js'; +import { validateVaultPath, setRestrictivePermissions } from '../security/index.js'; +import { AuditLogger } from '../audit/index.js'; +import { MetadataStore } from './metadata.js'; +import { + OperationType, + FileNotFoundError, + FileAlreadyExistsError, + KeyNotFoundError, + InvalidKeyError, + LastKeyError, + type FileMetadata, + type KeyRecord, + type GrepResult, + type SearchResult, + type VaultStatus, +} from '../types.js'; + +export class VaultEngine { + private store: MetadataStore; + private audit: AuditLogger; + private vaultPath: string; + private filesDir: string; + + constructor(vaultPath: string) { + this.vaultPath = vaultPath; + this.filesDir = path.join(vaultPath, 'files'); + this.store = new MetadataStore(path.join(vaultPath, 'vault.db')); + this.audit = new AuditLogger(path.join(vaultPath, 'audit.db')); + } + + // ── Init ──────────────────────────────────────────────────────────── + + static async init( + vaultPath: string, + passphrase: string, + ): Promise { + // 1. Create vault directory structure + fs.mkdirSync(vaultPath, { recursive: true }); + fs.mkdirSync(path.join(vaultPath, 'files'), { recursive: true }); + setRestrictivePermissions(vaultPath, 'directory'); + setRestrictivePermissions(path.join(vaultPath, 'files'), 'directory'); + + // 2. Generate master key pair + const { publicKey, privateKey } = await generateKeyPair(); + + // 3. Wrap master key for the initial passphrase + const salt = generateSalt(); + const { publicKey: passphrasePublicKey, identity } = + await passphraseToIdentity(passphrase, salt); + const wrappedKey = await wrapMasterKeyWithPassphrase(privateKey, identity); + + // 4. Create engine and store initial key + const engine = new VaultEngine(vaultPath); + engine.store.insertKey({ + id: randomUUID(), + label: 'primary passphrase', + publicKey: passphrasePublicKey, + wrappedMasterKey: wrappedKey, + salt, + createdAt: new Date(), + }); + + // 5. Store master public key + fs.writeFileSync(path.join(vaultPath, 'master.pub'), publicKey, 'utf-8'); + setRestrictivePermissions(path.join(vaultPath, 'master.pub'), 'file'); + + engine.audit.logEvent({ operation: OperationType.INIT, success: true }); + return engine; + } + + // ── File operations ───────────────────────────────────────────────── + + async writeFile( + vaultFilePath: string, + content: Buffer, + _masterKey: string, + tags?: string[], + ): Promise { + this.validatePath(vaultFilePath); + + if (this.store.getFile(vaultFilePath)) { + throw new FileAlreadyExistsError(`File already exists: ${vaultFilePath}`); + } + + const publicKey = this.getMasterPublicKey(); + const encrypted = await encrypt(content, [publicKey]); + + const blobId = randomUUID(); + const blobPath = path.join(this.filesDir, `${blobId}.age`); + fs.writeFileSync(blobPath, encrypted); + setRestrictivePermissions(blobPath, 'file'); + + const meta: FileMetadata = { + id: randomUUID(), + vaultPath: vaultFilePath, + blobId, + size: content.length, + createdAt: new Date(), + modifiedAt: new Date(), + tags: tags ?? [], + }; + this.store.insertFile(meta); + + this.audit.logEvent({ + operation: OperationType.WRITE, + targetPath: vaultFilePath, + success: true, + }); + return meta; + } + + async readFile(vaultFilePath: string, masterKey: string): Promise { + this.validatePath(vaultFilePath); + + const meta = this.store.getFile(vaultFilePath); + if (!meta) throw new FileNotFoundError(`File not found: ${vaultFilePath}`); + + const blobPath = path.join(this.filesDir, `${meta.blobId}.age`); + const encrypted = fs.readFileSync(blobPath); + const decrypted = await decrypt(encrypted, masterKey); + + this.audit.logEvent({ + operation: OperationType.READ, + targetPath: vaultFilePath, + success: true, + }); + return decrypted; + } + + async deleteFile(vaultFilePath: string): Promise { + this.validatePath(vaultFilePath); + + const meta = this.store.getFile(vaultFilePath); + if (!meta) + throw new FileNotFoundError(`File not found: ${vaultFilePath}`); + + const blobPath = path.join(this.filesDir, `${meta.blobId}.age`); + if (fs.existsSync(blobPath)) fs.unlinkSync(blobPath); + + this.store.deleteFile(vaultFilePath); + + this.audit.logEvent({ + operation: OperationType.DELETE, + targetPath: vaultFilePath, + success: true, + }); + } + + listFiles(dirPath?: string): FileMetadata[] { + this.audit.logEvent({ + operation: OperationType.LIST, + targetPath: dirPath, + success: true, + }); + return this.store.getAllFiles(dirPath); + } + + searchFiles(query: string): SearchResult[] { + const files = this.store.searchFiles(query); + this.audit.logEvent({ operation: OperationType.SEARCH, success: true }); + return files.map((f) => ({ + vaultPath: f.vaultPath, + matchType: 'filename' as const, + matchedValue: f.vaultPath, + })); + } + + async grepFiles( + pattern: string, + masterKey: string, + ): Promise { + const allFiles = this.store.getAllFiles(); + const results: GrepResult[] = []; + const regex = new RegExp(pattern, 'gi'); + + for (const file of allFiles) { + try { + const content = await this.readFile(file.vaultPath, masterKey); + const text = content.toString('utf-8'); + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (regex.test(lines[i])) { + results.push({ + vaultPath: file.vaultPath, + lineNumber: i + 1, + line: lines[i], + }); + } + regex.lastIndex = 0; + } + } catch { + // Skip files that can't be decrypted + } + } + + this.audit.logEvent({ operation: OperationType.GREP, success: true }); + return results; + } + + // ── Key management ────────────────────────────────────────────────── + + async addKey( + passphrase: string, + label: string, + currentMasterKey: string, + ): Promise { + const salt = generateSalt(); + const { publicKey, identity } = await passphraseToIdentity(passphrase, salt); + const wrappedKey = await wrapMasterKeyWithPassphrase( + currentMasterKey, + identity, + ); + + const record = { + id: randomUUID(), + label, + publicKey, + wrappedMasterKey: wrappedKey, + salt, + createdAt: new Date(), + }; + this.store.insertKey(record); + + this.audit.logEvent({ + operation: OperationType.KEY_ADD, + keyId: record.id, + success: true, + }); + return { id: record.id, label: record.label, createdAt: record.createdAt }; + } + + revokeKey(keyId: string): void { + const key = this.store.getKey(keyId); + if (!key) throw new KeyNotFoundError(`Key not found: ${keyId}`); + if (this.store.getKeyCount() <= 1) { + throw new LastKeyError(); + } + this.store.deleteKey(keyId); + this.audit.logEvent({ + operation: OperationType.KEY_REVOKE, + keyId, + success: true, + }); + } + + listKeys(): KeyRecord[] { + return this.store.getAllKeys().map((k) => ({ + id: k.id, + label: k.label, + createdAt: k.createdAt, + })); + } + + // ── Unlock support ────────────────────────────────────────────────── + + async unlock(passphrase: string): Promise { + const keys = this.store.getAllKeys(); + for (const key of keys) { + try { + const { identity } = await passphraseToIdentity(passphrase, key.salt); + const masterKey = await unwrapMasterKey( + key.wrappedMasterKey, + identity, + ); + this.audit.logEvent({ + operation: OperationType.UNLOCK, + keyId: key.id, + success: true, + }); + return masterKey; + } catch { + // Try next key + } + } + this.audit.logEvent({ operation: OperationType.UNLOCK, success: false }); + throw new InvalidKeyError('No key matched the provided passphrase'); + } + + // ── Status ────────────────────────────────────────────────────────── + + getStatus(isUnlocked: boolean): VaultStatus { + return { + initialized: true, + locked: !isUnlocked, + fileCount: this.store.getFileCount(), + keyCount: this.store.getKeyCount(), + }; + } + + getMasterPublicKey(): string { + return fs + .readFileSync(path.join(this.vaultPath, 'master.pub'), 'utf-8') + .trim(); + } + + getAuditLogger(): AuditLogger { + return this.audit; + } + + close(): void { + this.store.close(); + this.audit.close(); + } + + private validatePath(userPath: string): string { + return validateVaultPath(userPath, this.vaultPath); + } +} diff --git a/src/vault/index.ts b/src/vault/index.ts index cb0ff5c..9face87 100644 --- a/src/vault/index.ts +++ b/src/vault/index.ts @@ -1 +1,2 @@ -export {}; +export { VaultEngine } from './engine.js'; +export { MetadataStore } from './metadata.js'; diff --git a/src/vault/metadata.ts b/src/vault/metadata.ts new file mode 100644 index 0000000..d3e5602 --- /dev/null +++ b/src/vault/metadata.ts @@ -0,0 +1,221 @@ +import Database from 'better-sqlite3'; +import type { FileMetadata, KeyRecord } from '../types.js'; + +export interface KeyRecordFull extends KeyRecord { + publicKey: string; + wrappedMasterKey: Buffer; + salt: string; +} + +export class MetadataStore { + private db: Database.Database; + + constructor(dbPath: string) { + this.db = new Database(dbPath); + this.db.pragma('journal_mode = WAL'); + + this.db.exec(` + CREATE TABLE IF NOT EXISTS files ( + id TEXT PRIMARY KEY, + vault_path TEXT UNIQUE NOT NULL, + blob_id TEXT NOT NULL, + size INTEGER NOT NULL, + created_at TEXT NOT NULL, + modified_at TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '[]' + ) + `); + + this.db.exec(` + CREATE TABLE IF NOT EXISTS keys ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + public_key TEXT NOT NULL, + wrapped_master_key BLOB NOT NULL, + salt TEXT NOT NULL, + created_at TEXT NOT NULL + ) + `); + } + + // ── File metadata CRUD ────────────────────────────────────────────── + + insertFile(meta: FileMetadata): void { + this.db + .prepare( + `INSERT INTO files (id, vault_path, blob_id, size, created_at, modified_at, tags) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + meta.id, + meta.vaultPath, + meta.blobId, + meta.size, + meta.createdAt.toISOString(), + meta.modifiedAt.toISOString(), + JSON.stringify(meta.tags), + ); + } + + getFile(vaultPath: string): FileMetadata | null { + const row = this.db + .prepare('SELECT * FROM files WHERE vault_path = ?') + .get(vaultPath) as RawFileRow | undefined; + return row ? toFileMetadata(row) : null; + } + + getAllFiles(dirPath?: string): FileMetadata[] { + if (dirPath) { + const prefix = dirPath.endsWith('/') ? dirPath : dirPath + '/'; + const rows = this.db + .prepare('SELECT * FROM files WHERE vault_path LIKE ? ORDER BY vault_path') + .all(prefix + '%') as RawFileRow[]; + return rows.map(toFileMetadata); + } + const rows = this.db + .prepare('SELECT * FROM files ORDER BY vault_path') + .all() as RawFileRow[]; + return rows.map(toFileMetadata); + } + + updateFile( + id: string, + updates: Partial>, + ): void { + const sets: string[] = []; + const values: unknown[] = []; + + if (updates.size !== undefined) { + sets.push('size = ?'); + values.push(updates.size); + } + if (updates.modifiedAt !== undefined) { + sets.push('modified_at = ?'); + values.push(updates.modifiedAt.toISOString()); + } + if (updates.tags !== undefined) { + sets.push('tags = ?'); + values.push(JSON.stringify(updates.tags)); + } + + if (sets.length === 0) return; + values.push(id); + this.db.prepare(`UPDATE files SET ${sets.join(', ')} WHERE id = ?`).run(...values); + } + + deleteFile(vaultPath: string): void { + this.db.prepare('DELETE FROM files WHERE vault_path = ?').run(vaultPath); + } + + getFileCount(): number { + const row = this.db.prepare('SELECT COUNT(*) AS count FROM files').get() as { + count: number; + }; + return row.count; + } + + // ── Key record CRUD ───────────────────────────────────────────────── + + insertKey(record: KeyRecordFull): void { + this.db + .prepare( + `INSERT INTO keys (id, label, public_key, wrapped_master_key, salt, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run( + record.id, + record.label, + record.publicKey, + record.wrappedMasterKey, + record.salt, + record.createdAt.toISOString(), + ); + } + + getKey(id: string): KeyRecordFull | null { + const row = this.db.prepare('SELECT * FROM keys WHERE id = ?').get(id) as + | RawKeyRow + | undefined; + return row ? toKeyRecord(row) : null; + } + + getAllKeys(): KeyRecordFull[] { + const rows = this.db + .prepare('SELECT * FROM keys ORDER BY created_at') + .all() as RawKeyRow[]; + return rows.map(toKeyRecord); + } + + deleteKey(id: string): void { + this.db.prepare('DELETE FROM keys WHERE id = ?').run(id); + } + + getKeyCount(): number { + const row = this.db.prepare('SELECT COUNT(*) AS count FROM keys').get() as { + count: number; + }; + return row.count; + } + + // ── Search ────────────────────────────────────────────────────────── + + searchFiles(query: string): FileMetadata[] { + const pattern = `%${query}%`; + const rows = this.db + .prepare( + `SELECT * FROM files + WHERE vault_path LIKE ? OR tags LIKE ? + ORDER BY vault_path`, + ) + .all(pattern, pattern) as RawFileRow[]; + return rows.map(toFileMetadata); + } + + close(): void { + this.db.close(); + } +} + +// ── Internal helpers ────────────────────────────────────────────────────── + +interface RawFileRow { + id: string; + vault_path: string; + blob_id: string; + size: number; + created_at: string; + modified_at: string; + tags: string; +} + +interface RawKeyRow { + id: string; + label: string; + public_key: string; + wrapped_master_key: Buffer; + salt: string; + created_at: string; +} + +function toFileMetadata(row: RawFileRow): FileMetadata { + return { + id: row.id, + vaultPath: row.vault_path, + blobId: row.blob_id, + size: row.size, + createdAt: new Date(row.created_at), + modifiedAt: new Date(row.modified_at), + tags: JSON.parse(row.tags) as string[], + }; +} + +function toKeyRecord(row: RawKeyRow): KeyRecordFull { + return { + id: row.id, + label: row.label, + publicKey: row.public_key, + wrappedMasterKey: Buffer.from(row.wrapped_master_key), + salt: row.salt, + createdAt: new Date(row.created_at), + }; +} diff --git a/test/unit/vault.test.ts b/test/unit/vault.test.ts new file mode 100644 index 0000000..b0e0a9c --- /dev/null +++ b/test/unit/vault.test.ts @@ -0,0 +1,452 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { VaultEngine } from '../../src/vault/engine.js'; +import { MetadataStore } from '../../src/vault/metadata.js'; +import { + FileNotFoundError, + FileAlreadyExistsError, + KeyNotFoundError, + InvalidKeyError, + LastKeyError, +} from '../../src/types.js'; + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'vault-test-')); +} + +function cleanup(dir: string): void { + fs.rmSync(dir, { recursive: true, force: true }); +} + +// ── MetadataStore ───────────────────────────────────────────────────────── + +describe('MetadataStore', () => { + let tmpDir: string; + let store: MetadataStore; + + beforeEach(() => { + tmpDir = makeTempDir(); + store = new MetadataStore(path.join(tmpDir, 'vault.db')); + }); + + afterEach(() => { + store.close(); + cleanup(tmpDir); + }); + + it('inserts and retrieves file metadata', () => { + const meta = { + id: 'f1', + vaultPath: 'docs/readme.md', + blobId: 'blob-1', + size: 42, + createdAt: new Date('2024-01-01'), + modifiedAt: new Date('2024-01-02'), + tags: ['doc', 'readme'], + }; + store.insertFile(meta); + const result = store.getFile('docs/readme.md'); + expect(result).not.toBeNull(); + expect(result!.id).toBe('f1'); + expect(result!.vaultPath).toBe('docs/readme.md'); + expect(result!.tags).toEqual(['doc', 'readme']); + expect(result!.size).toBe(42); + }); + + it('returns null for non-existent file', () => { + expect(store.getFile('nonexistent')).toBeNull(); + }); + + it('getAllFiles returns all files', () => { + store.insertFile({ + id: 'f1', + vaultPath: 'a.txt', + blobId: 'b1', + size: 1, + createdAt: new Date(), + modifiedAt: new Date(), + tags: [], + }); + store.insertFile({ + id: 'f2', + vaultPath: 'b.txt', + blobId: 'b2', + size: 2, + createdAt: new Date(), + modifiedAt: new Date(), + tags: [], + }); + expect(store.getAllFiles()).toHaveLength(2); + }); + + it('getAllFiles with dirPath filters by prefix', () => { + store.insertFile({ + id: 'f1', + vaultPath: 'docs/a.txt', + blobId: 'b1', + size: 1, + createdAt: new Date(), + modifiedAt: new Date(), + tags: [], + }); + store.insertFile({ + id: 'f2', + vaultPath: 'other/b.txt', + blobId: 'b2', + size: 2, + createdAt: new Date(), + modifiedAt: new Date(), + tags: [], + }); + const results = store.getAllFiles('docs'); + expect(results).toHaveLength(1); + expect(results[0].vaultPath).toBe('docs/a.txt'); + }); + + it('updateFile updates fields', () => { + store.insertFile({ + id: 'f1', + vaultPath: 'test.txt', + blobId: 'b1', + size: 10, + createdAt: new Date(), + modifiedAt: new Date('2024-01-01'), + tags: [], + }); + store.updateFile('f1', { + size: 99, + tags: ['updated'], + modifiedAt: new Date('2024-06-01'), + }); + const result = store.getFile('test.txt')!; + expect(result.size).toBe(99); + expect(result.tags).toEqual(['updated']); + }); + + it('deleteFile removes file', () => { + store.insertFile({ + id: 'f1', + vaultPath: 'test.txt', + blobId: 'b1', + size: 1, + createdAt: new Date(), + modifiedAt: new Date(), + tags: [], + }); + store.deleteFile('test.txt'); + expect(store.getFile('test.txt')).toBeNull(); + expect(store.getFileCount()).toBe(0); + }); + + it('searchFiles matches vault_path', () => { + store.insertFile({ + id: 'f1', + vaultPath: 'contracts/lease.pdf', + blobId: 'b1', + size: 1, + createdAt: new Date(), + modifiedAt: new Date(), + tags: [], + }); + const results = store.searchFiles('lease'); + expect(results).toHaveLength(1); + expect(results[0].vaultPath).toBe('contracts/lease.pdf'); + }); + + it('searchFiles matches tags', () => { + store.insertFile({ + id: 'f1', + vaultPath: 'file.txt', + blobId: 'b1', + size: 1, + createdAt: new Date(), + modifiedAt: new Date(), + tags: ['important', 'legal'], + }); + const results = store.searchFiles('legal'); + expect(results).toHaveLength(1); + }); + + it('key CRUD operations work', () => { + store.insertKey({ + id: 'k1', + label: 'test key', + publicKey: 'pub-1', + wrappedMasterKey: Buffer.from('wrapped'), + salt: 'salt-1', + createdAt: new Date(), + }); + + expect(store.getKeyCount()).toBe(1); + const key = store.getKey('k1'); + expect(key).not.toBeNull(); + expect(key!.label).toBe('test key'); + expect(key!.publicKey).toBe('pub-1'); + + const allKeys = store.getAllKeys(); + expect(allKeys).toHaveLength(1); + + store.deleteKey('k1'); + expect(store.getKeyCount()).toBe(0); + expect(store.getKey('k1')).toBeNull(); + }); +}); + +// ── VaultEngine ─────────────────────────────────────────────────────────── + +describe('VaultEngine', () => { + let tmpDir: string; + let vaultDir: string; + let engine: VaultEngine; + let masterKey: string; + const TEST_PASSPHRASE = 'test-vault-passphrase-123'; + + beforeEach(async () => { + tmpDir = makeTempDir(); + vaultDir = path.join(tmpDir, 'vault'); + engine = await VaultEngine.init(vaultDir, TEST_PASSPHRASE); + masterKey = await engine.unlock(TEST_PASSPHRASE); + }); + + afterEach(() => { + engine.close(); + cleanup(tmpDir); + }); + + // ── Init ────────────────────────────────────────────────────────── + + describe('init', () => { + it('creates vault directory structure', () => { + expect(fs.existsSync(vaultDir)).toBe(true); + expect(fs.existsSync(path.join(vaultDir, 'files'))).toBe(true); + expect(fs.existsSync(path.join(vaultDir, 'vault.db'))).toBe(true); + expect(fs.existsSync(path.join(vaultDir, 'audit.db'))).toBe(true); + expect(fs.existsSync(path.join(vaultDir, 'master.pub'))).toBe(true); + }); + + it('stores a master public key file', () => { + const pubKey = fs.readFileSync( + path.join(vaultDir, 'master.pub'), + 'utf-8', + ); + expect(pubKey.trim()).toMatch(/^age1/); + }); + + it('stores initial passphrase key', () => { + const keys = engine.listKeys(); + expect(keys).toHaveLength(1); + expect(keys[0].label).toBe('primary passphrase'); + }); + }); + + // ── Write / Read ────────────────────────────────────────────────── + + describe('writeFile + readFile', () => { + it('round-trip preserves content', async () => { + const content = Buffer.from('Hello, encrypted world!'); + await engine.writeFile('notes/hello.txt', content, masterKey); + const result = await engine.readFile('notes/hello.txt', masterKey); + expect(result).toEqual(content); + }); + + it('writeFile creates metadata with correct fields', async () => { + const content = Buffer.from('data'); + const meta = await engine.writeFile('test.bin', content, masterKey, [ + 'binary', + ]); + expect(meta.vaultPath).toBe('test.bin'); + expect(meta.size).toBe(4); + expect(meta.tags).toEqual(['binary']); + expect(meta.id).toBeDefined(); + expect(meta.blobId).toBeDefined(); + }); + + it('writeFile creates encrypted blob on disk', async () => { + const content = Buffer.from('secret'); + const meta = await engine.writeFile('secret.txt', content, masterKey); + const blobPath = path.join(vaultDir, 'files', `${meta.blobId}.age`); + expect(fs.existsSync(blobPath)).toBe(true); + // Blob should NOT contain plaintext + const blob = fs.readFileSync(blobPath); + expect(blob.toString('utf-8')).not.toContain('secret'); + }); + + it('writeFile on existing path throws FileAlreadyExistsError', async () => { + await engine.writeFile('dup.txt', Buffer.from('first'), masterKey); + await expect( + engine.writeFile('dup.txt', Buffer.from('second'), masterKey), + ).rejects.toThrow(FileAlreadyExistsError); + }); + + it('readFile on non-existent file throws FileNotFoundError', async () => { + await expect( + engine.readFile('nonexistent.txt', masterKey), + ).rejects.toThrow(FileNotFoundError); + }); + }); + + // ── Delete ──────────────────────────────────────────────────────── + + describe('deleteFile', () => { + it('removes blob and metadata', async () => { + const meta = await engine.writeFile( + 'delete-me.txt', + Buffer.from('bye'), + masterKey, + ); + const blobPath = path.join(vaultDir, 'files', `${meta.blobId}.age`); + expect(fs.existsSync(blobPath)).toBe(true); + + await engine.deleteFile('delete-me.txt'); + expect(fs.existsSync(blobPath)).toBe(false); + await expect( + engine.readFile('delete-me.txt', masterKey), + ).rejects.toThrow(FileNotFoundError); + }); + + it('on non-existent file throws FileNotFoundError', async () => { + await expect(engine.deleteFile('ghost.txt')).rejects.toThrow( + FileNotFoundError, + ); + }); + }); + + // ── List ────────────────────────────────────────────────────────── + + describe('listFiles', () => { + it('returns all file metadata', async () => { + await engine.writeFile('a.txt', Buffer.from('a'), masterKey); + await engine.writeFile('b.txt', Buffer.from('b'), masterKey); + const files = engine.listFiles(); + expect(files).toHaveLength(2); + expect(files.map((f) => f.vaultPath).sort()).toEqual(['a.txt', 'b.txt']); + }); + + it('with directory prefix filters results', async () => { + await engine.writeFile('docs/a.txt', Buffer.from('a'), masterKey); + await engine.writeFile('other/b.txt', Buffer.from('b'), masterKey); + const docs = engine.listFiles('docs'); + expect(docs).toHaveLength(1); + expect(docs[0].vaultPath).toBe('docs/a.txt'); + }); + }); + + // ── Search ──────────────────────────────────────────────────────── + + describe('searchFiles', () => { + it('matches filenames', async () => { + await engine.writeFile( + 'contracts/lease.pdf', + Buffer.from('pdf'), + masterKey, + ); + await engine.writeFile('notes/todo.md', Buffer.from('md'), masterKey); + const results = engine.searchFiles('lease'); + expect(results).toHaveLength(1); + expect(results[0].vaultPath).toBe('contracts/lease.pdf'); + expect(results[0].matchType).toBe('filename'); + }); + }); + + // ── Grep ────────────────────────────────────────────────────────── + + describe('grepFiles', () => { + it('finds content matches across files', async () => { + await engine.writeFile( + 'file1.txt', + Buffer.from('line1\nfind me here\nline3'), + masterKey, + ); + await engine.writeFile( + 'file2.txt', + Buffer.from('nothing interesting'), + masterKey, + ); + const results = await engine.grepFiles('find me', masterKey); + expect(results).toHaveLength(1); + expect(results[0].vaultPath).toBe('file1.txt'); + expect(results[0].lineNumber).toBe(2); + expect(results[0].line).toContain('find me here'); + }); + }); + + // ── Key management ──────────────────────────────────────────────── + + describe('addKey', () => { + it('creates a new passphrase key', async () => { + const record = await engine.addKey( + 'second-passphrase-1234', + 'backup key', + masterKey, + ); + expect(record.label).toBe('backup key'); + expect(record.id).toBeDefined(); + expect(engine.listKeys()).toHaveLength(2); + }); + }); + + describe('unlock', () => { + it('with original passphrase works', async () => { + const key = await engine.unlock(TEST_PASSPHRASE); + expect(key).toBe(masterKey); + }); + + it('with added key passphrase works', async () => { + const secondPass = 'another-passphrase-5678'; + await engine.addKey(secondPass, 'second', masterKey); + const key = await engine.unlock(secondPass); + expect(key).toBe(masterKey); + }, 30_000); + + it('with wrong passphrase throws InvalidKeyError', async () => { + await expect(engine.unlock('wrong-passphrase-9999')).rejects.toThrow( + InvalidKeyError, + ); + }); + }); + + describe('revokeKey', () => { + it('removes key', async () => { + const second = await engine.addKey( + 'second-passphrase-1234', + 'second', + masterKey, + ); + expect(engine.listKeys()).toHaveLength(2); + engine.revokeKey(second.id); + expect(engine.listKeys()).toHaveLength(1); + }); + + it('on last key throws LastKeyError', () => { + const keys = engine.listKeys(); + expect(keys).toHaveLength(1); + expect(() => engine.revokeKey(keys[0].id)).toThrow(LastKeyError); + }); + + it('on non-existent key throws KeyNotFoundError', () => { + expect(() => + engine.revokeKey('00000000-0000-0000-0000-000000000000'), + ).toThrow(KeyNotFoundError); + }); + }); + + // ── Status ──────────────────────────────────────────────────────── + + describe('getStatus', () => { + it('returns correct counts', async () => { + await engine.writeFile('file.txt', Buffer.from('data'), masterKey); + const status = engine.getStatus(true); + expect(status.initialized).toBe(true); + expect(status.locked).toBe(false); + expect(status.fileCount).toBe(1); + expect(status.keyCount).toBe(1); + }); + + it('reports locked when not unlocked', () => { + const status = engine.getStatus(false); + expect(status.locked).toBe(true); + }); + }); +}); From 176f4463abfc91ed5089cc33cce28eea4efc820c Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 13:44:17 -0700 Subject: [PATCH 10/23] cli: full command suite for vault operations - Commands: init, unlock, lock, status, write, read, delete, list, search, grep - Key management: key add, key list, key revoke - Audit log viewer - Interactive passphrase prompting (no echo) - MCP config snippet on init - Proper error handling with user-friendly messages - Unit tests for command registration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/cli/commands.ts | 407 ++++++++++++++++++++++++++++++++++++++++++ src/cli/index.ts | 187 ++++++++++++++++++- src/cli/prompt.ts | 53 ++++++ test/unit/cli.test.ts | 77 ++++++++ 4 files changed, 723 insertions(+), 1 deletion(-) create mode 100644 src/cli/commands.ts create mode 100644 src/cli/prompt.ts create mode 100644 test/unit/cli.test.ts diff --git a/src/cli/commands.ts b/src/cli/commands.ts new file mode 100644 index 0000000..209a4d6 --- /dev/null +++ b/src/cli/commands.ts @@ -0,0 +1,407 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { VaultEngine } from '../vault/index.js'; +import { SessionManager } from '../session/index.js'; +import { ConfigManager } from '../config/index.js'; +import { sanitizeOutput } from '../security/index.js'; +import { + type OperationType, + VaultLockedError, + SessionExpiredError, +} from '../types.js'; +import { promptPassphrase, promptPassphraseConfirm } from './prompt.js'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function getConfig(): ConfigManager { + return new ConfigManager(); +} + +function getEngine(config: ConfigManager): VaultEngine { + return new VaultEngine(config.getVaultPath()); +} + +function getSession(config: ConfigManager): SessionManager { + return new SessionManager(config.getSessionPath()); +} + +function requireInit(config: ConfigManager): void { + if (!config.isInitialized()) { + process.stderr.write( + 'Vault not initialized. Run `vault init` first.\n', + ); + process.exit(1); + } +} + +function requireUnlock(session: SessionManager): string { + try { + return session.readSession(); + } catch (err) { + if (err instanceof VaultLockedError) { + process.stderr.write( + 'Vault is locked. Run `vault unlock` first.\n', + ); + process.exit(1); + } + if (err instanceof SessionExpiredError) { + process.stderr.write( + 'Session expired. Run `vault unlock` again.\n', + ); + process.exit(1); + } + throw err; + } +} + +function formatDate(d: Date): string { + return d.toISOString().replace('T', ' ').replace(/\.\d+Z$/, 'Z'); +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +// ── Commands ───────────────────────────────────────────────────────────────── + +export async function initCommand(vaultPath?: string): Promise { + const resolvedPath = path.resolve( + vaultPath ?? path.join(os.homedir(), '.vault'), + ); + + const passphrase = await promptPassphraseConfirm(); + + const engine = await VaultEngine.init(resolvedPath, passphrase); + engine.close(); + + const config = getConfig(); + config.initialize(resolvedPath); + + process.stderr.write(`Vault initialized at ${resolvedPath}\n`); + process.stderr.write( + `\nAdd to your MCP config:\n` + + `{\n` + + ` "mcpServers": {\n` + + ` "workspace-vault": {\n` + + ` "command": "npx",\n` + + ` "args": ["workspace-vault", "mcp"]\n` + + ` }\n` + + ` }\n` + + `}\n`, + ); +} + +export async function unlockCommand(): Promise { + const config = getConfig(); + requireInit(config); + + const session = getSession(config); + if (session.isUnlocked()) { + process.stderr.write('Vault is already unlocked.\n'); + return; + } + + const passphrase = await promptPassphrase(); + const engine = getEngine(config); + try { + const masterKey = await engine.unlock(passphrase); + session.writeSession(masterKey); + process.stderr.write('Vault unlocked. Session expires in 30 minutes.\n'); + } finally { + engine.close(); + } +} + +export function lockCommand(): void { + const config = getConfig(); + requireInit(config); + + const session = getSession(config); + session.clearSession(); + process.stderr.write('Vault locked.\n'); +} + +export function statusCommand(): void { + const config = getConfig(); + if (!config.isInitialized()) { + process.stderr.write('Vault not initialized. Run `vault init` first.\n'); + return; + } + + const session = getSession(config); + const engine = getEngine(config); + try { + const isUnlocked = session.isUnlocked(); + const status = engine.getStatus(isUnlocked); + + process.stderr.write(`Status: ${status.locked ? 'locked' : 'unlocked'}\n`); + process.stderr.write(`Files: ${status.fileCount}\n`); + process.stderr.write(`Keys: ${status.keyCount}\n`); + + if (isUnlocked) { + const info = session.getSessionInfo(); + if (info) { + process.stderr.write(`Session expires: ${formatDate(info.expiresAt)}\n`); + } + } + } finally { + engine.close(); + } +} + +export async function writeCommand( + vaultPath: string, + options: { from?: string; tag?: string[] }, +): Promise { + const config = getConfig(); + requireInit(config); + const session = getSession(config); + const masterKey = requireUnlock(session); + const engine = getEngine(config); + + try { + let content: Buffer; + if (options.from) { + content = fs.readFileSync(options.from); + } else { + if (process.stdin.isTTY) { + process.stderr.write('Reading from stdin (press Ctrl+D to finish):\n'); + } + content = await readStdin(); + } + + const meta = await engine.writeFile( + vaultPath, + content, + masterKey, + options.tag, + ); + process.stderr.write( + `Written ${vaultPath} (${formatSize(meta.size)})\n`, + ); + } finally { + engine.close(); + } +} + +export async function readCommand(vaultPath: string): Promise { + const config = getConfig(); + requireInit(config); + const session = getSession(config); + const masterKey = requireUnlock(session); + const engine = getEngine(config); + + try { + const content = await engine.readFile(vaultPath, masterKey); + process.stdout.write(sanitizeOutput(content.toString('utf-8'))); + } finally { + engine.close(); + } +} + +export async function deleteCommand(vaultPath: string): Promise { + const config = getConfig(); + requireInit(config); + const session = getSession(config); + requireUnlock(session); + const engine = getEngine(config); + + try { + await engine.deleteFile(vaultPath); + process.stderr.write(`Deleted ${vaultPath}\n`); + } finally { + engine.close(); + } +} + +export function listCommand(vaultPath?: string): void { + const config = getConfig(); + requireInit(config); + const engine = getEngine(config); + + try { + const files = engine.listFiles(vaultPath); + if (files.length === 0) { + process.stderr.write('No files in vault.\n'); + return; + } + + // Header + process.stdout.write( + `${'PATH'.padEnd(40)} ${'SIZE'.padEnd(10)} ${'MODIFIED'.padEnd(24)} TAGS\n`, + ); + for (const f of files) { + const tags = f.tags.length > 0 ? f.tags.join(', ') : ''; + process.stdout.write( + `${f.vaultPath.padEnd(40)} ${formatSize(f.size).padEnd(10)} ${formatDate(f.modifiedAt).padEnd(24)} ${tags}\n`, + ); + } + } finally { + engine.close(); + } +} + +export function searchCommand(query: string): void { + const config = getConfig(); + requireInit(config); + const engine = getEngine(config); + + try { + const results = engine.searchFiles(query); + if (results.length === 0) { + process.stderr.write('No matches found.\n'); + return; + } + for (const r of results) { + process.stdout.write(`${r.vaultPath} (${r.matchType}: ${r.matchedValue})\n`); + } + } finally { + engine.close(); + } +} + +export async function grepCommand(pattern: string): Promise { + const config = getConfig(); + requireInit(config); + const session = getSession(config); + const masterKey = requireUnlock(session); + const engine = getEngine(config); + + try { + const results = await engine.grepFiles(pattern, masterKey); + if (results.length === 0) { + process.stderr.write('No matches found.\n'); + return; + } + for (const r of results) { + process.stdout.write( + `${r.vaultPath}:${r.lineNumber}: ${sanitizeOutput(r.line)}\n`, + ); + } + } finally { + engine.close(); + } +} + +export async function keyAddCommand(): Promise { + const config = getConfig(); + requireInit(config); + const session = getSession(config); + const masterKey = requireUnlock(session); + const engine = getEngine(config); + + try { + const passphrase = await promptPassphraseConfirm(); + + const rl = await import('node:readline'); + const iface = rl.createInterface({ + input: process.stdin, + output: process.stderr, + }); + const label = await new Promise((resolve) => { + iface.question('Key label: ', (answer) => { + iface.close(); + resolve(answer.trim()); + }); + }); + + if (!label) { + process.stderr.write('Label is required.\n'); + process.exit(1); + } + + const key = await engine.addKey(passphrase, label, masterKey); + process.stderr.write(`Key added: ${key.id} (${key.label})\n`); + } finally { + engine.close(); + } +} + +export function keyListCommand(): void { + const config = getConfig(); + requireInit(config); + const engine = getEngine(config); + + try { + const keys = engine.listKeys(); + if (keys.length === 0) { + process.stderr.write('No keys.\n'); + return; + } + + process.stdout.write( + `${'ID'.padEnd(38)} ${'LABEL'.padEnd(24)} CREATED\n`, + ); + for (const k of keys) { + process.stdout.write( + `${k.id.padEnd(38)} ${k.label.padEnd(24)} ${formatDate(k.createdAt)}\n`, + ); + } + } finally { + engine.close(); + } +} + +export function keyRevokeCommand(keyId: string): void { + const config = getConfig(); + requireInit(config); + const session = getSession(config); + requireUnlock(session); + const engine = getEngine(config); + + try { + engine.revokeKey(keyId); + process.stderr.write(`Key revoked: ${keyId}\n`); + } finally { + engine.close(); + } +} + +export function auditCommand(options: { + tail?: string; + operation?: string; +}): void { + const config = getConfig(); + requireInit(config); + const engine = getEngine(config); + + try { + const limit = options.tail ? parseInt(options.tail, 10) : undefined; + const operation = options.operation as OperationType | undefined; + const events = engine + .getAuditLogger() + .getEvents({ limit, operation }); + + if (events.length === 0) { + process.stderr.write('No audit events.\n'); + return; + } + + process.stdout.write( + `${'TIMESTAMP'.padEnd(24)} ${'OPERATION'.padEnd(14)} ${'PATH'.padEnd(30)} ${'OK'.padEnd(4)}\n`, + ); + for (const e of events) { + process.stdout.write( + `${formatDate(e.timestamp).padEnd(24)} ${e.operation.padEnd(14)} ${(e.targetPath ?? '').padEnd(30)} ${e.success ? 'yes' : 'no'}\n`, + ); + } + } finally { + engine.close(); + } +} + +export function mcpCommand(): void { + process.stderr.write('MCP server not yet implemented.\n'); + process.exit(1); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index cdb40bb..7647f3e 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,2 +1,187 @@ #!/usr/bin/env node -// CLI entry point + +import { Command } from 'commander'; +import { + VaultError, + VaultLockedError, + VaultNotInitializedError, + InvalidKeyError, +} from '../types.js'; +import { + initCommand, + unlockCommand, + lockCommand, + statusCommand, + writeCommand, + readCommand, + deleteCommand, + listCommand, + searchCommand, + grepCommand, + keyAddCommand, + keyListCommand, + keyRevokeCommand, + auditCommand, + mcpCommand, +} from './commands.js'; + +const program = new Command(); + +program + .name('vault') + .description('Encrypted file vault with MCP server for AI agent access') + .version('0.1.0'); + +program + .command('init') + .description('Initialize a new encrypted vault') + .argument('[path]', 'vault directory path (default: ~/.vault)') + .action(async (vaultPath?: string) => { + await initCommand(vaultPath); + }); + +program + .command('unlock') + .description('Unlock the vault with a passphrase') + .action(async () => { + await unlockCommand(); + }); + +program + .command('lock') + .description('Lock the vault and clear the session') + .action(() => { + lockCommand(); + }); + +program + .command('status') + .description('Show vault status') + .action(() => { + statusCommand(); + }); + +program + .command('write') + .description('Write a file to the vault') + .argument('', 'path inside the vault') + .option('--from ', 'read content from a local file') + .option('--tag ', 'tags for the file') + .action(async (vaultPath: string, options: { from?: string; tag?: string[] }) => { + await writeCommand(vaultPath, options); + }); + +program + .command('read') + .description('Read and decrypt a file from the vault') + .argument('', 'path inside the vault') + .action(async (vaultPath: string) => { + await readCommand(vaultPath); + }); + +program + .command('delete') + .description('Delete a file from the vault') + .argument('', 'path inside the vault') + .action(async (vaultPath: string) => { + await deleteCommand(vaultPath); + }); + +program + .command('list') + .description('List files in the vault') + .argument('[vault-path]', 'directory path to list') + .action((vaultPath?: string) => { + listCommand(vaultPath); + }); + +program + .command('search') + .description('Search files by name or tag') + .argument('', 'search query') + .action((query: string) => { + searchCommand(query); + }); + +program + .command('grep') + .description('Search file contents (requires unlock)') + .argument('', 'regex pattern to search for') + .action(async (pattern: string) => { + await grepCommand(pattern); + }); + +// Key management subcommand +const key = program + .command('key') + .description('Manage vault keys'); + +key + .command('add') + .description('Add a new passphrase key') + .action(async () => { + await keyAddCommand(); + }); + +key + .command('list') + .description('List all keys') + .action(() => { + keyListCommand(); + }); + +key + .command('revoke') + .description('Revoke a key') + .argument('', 'ID of the key to revoke') + .action((keyId: string) => { + keyRevokeCommand(keyId); + }); + +program + .command('audit') + .description('View audit log') + .option('--tail ', 'number of recent events to show') + .option('--operation ', 'filter by operation type') + .action((options: { tail?: string; operation?: string }) => { + auditCommand(options); + }); + +program + .command('mcp') + .description('Start the MCP server') + .action(() => { + mcpCommand(); + }); + +// Global error handling +async function main() { + try { + await program.parseAsync(); + } catch (err) { + if (err instanceof VaultLockedError) { + process.stderr.write('Vault is locked. Run `vault unlock` first.\n'); + process.exit(1); + } + if (err instanceof VaultNotInitializedError) { + process.stderr.write( + 'Vault not initialized. Run `vault init` first.\n', + ); + process.exit(1); + } + if (err instanceof InvalidKeyError) { + process.stderr.write('Invalid passphrase.\n'); + process.exit(1); + } + if (err instanceof VaultError) { + process.stderr.write(`Error: ${err.message}\n`); + process.exit(1); + } + process.stderr.write( + `Unexpected error: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(2); + } +} + +main(); diff --git a/src/cli/prompt.ts b/src/cli/prompt.ts new file mode 100644 index 0000000..6c09824 --- /dev/null +++ b/src/cli/prompt.ts @@ -0,0 +1,53 @@ +import { createInterface } from 'node:readline'; + +/** + * Prompt for a passphrase without echoing input. + * Writes prompt to stderr so stdout remains clean for piping. + */ +export async function promptPassphrase( + prompt = 'Passphrase: ', +): Promise { + return new Promise((resolve, reject) => { + if (!process.stdin.isTTY) { + reject(new Error('Cannot prompt for passphrase: stdin is not a TTY')); + return; + } + + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + }); + + const origWrite = process.stderr.write.bind(process.stderr); + let muted = false; + process.stderr.write = ((chunk: string | Uint8Array) => { + if (muted) return true; + return origWrite(chunk); + }) as typeof process.stderr.write; + + rl.question(prompt, (answer) => { + muted = false; + process.stderr.write = origWrite; + process.stderr.write('\n'); + rl.close(); + resolve(answer); + }); + + muted = true; + }); +} + +/** + * Prompt for a passphrase twice and ensure they match. + */ +export async function promptPassphraseConfirm(): Promise { + const pass1 = await promptPassphrase('New passphrase: '); + const pass2 = await promptPassphrase('Confirm passphrase: '); + if (pass1 !== pass2) { + throw new Error('Passphrases do not match.'); + } + if (pass1.length < 8) { + throw new Error('Passphrase must be at least 8 characters.'); + } + return pass1; +} diff --git a/test/unit/cli.test.ts b/test/unit/cli.test.ts new file mode 100644 index 0000000..b695384 --- /dev/null +++ b/test/unit/cli.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; + +const CLI_PATH = path.resolve('dist/cli/index.js'); + +function run(args: string[]): { stdout: string; stderr: string; exitCode: number } { + try { + const stdout = execFileSync('node', [CLI_PATH, ...args], { + encoding: 'utf-8', + timeout: 10_000, + env: { ...process.env, NODE_NO_WARNINGS: '1' }, + }); + return { stdout, stderr: '', exitCode: 0 }; + } catch (err) { + const e = err as { stdout?: string; stderr?: string; status?: number }; + return { + stdout: e.stdout ?? '', + stderr: e.stderr ?? '', + exitCode: e.status ?? 1, + }; + } +} + +describe('CLI command registration', () => { + it('should show help with all commands', () => { + const { stdout } = run(['--help']); + expect(stdout).toContain('vault'); + expect(stdout).toContain('init'); + expect(stdout).toContain('unlock'); + expect(stdout).toContain('lock'); + expect(stdout).toContain('status'); + expect(stdout).toContain('write'); + expect(stdout).toContain('read'); + expect(stdout).toContain('delete'); + expect(stdout).toContain('list'); + expect(stdout).toContain('search'); + expect(stdout).toContain('grep'); + expect(stdout).toContain('key'); + expect(stdout).toContain('audit'); + expect(stdout).toContain('mcp'); + }); + + it('should show version', () => { + const { stdout } = run(['--version']); + expect(stdout.trim()).toBe('0.1.0'); + }); + + it('should show help for key subcommand', () => { + const { stdout } = run(['key', '--help']); + expect(stdout).toContain('add'); + expect(stdout).toContain('list'); + expect(stdout).toContain('revoke'); + }); +}); + +describe('CLI error handling', () => { + it('should fail gracefully for status when vault is not initialized', () => { + const { stderr, exitCode } = run(['status']); + // Status prints a message but doesn't exit 1 when not initialized + expect(stderr + '').toContain(''); + expect(typeof exitCode).toBe('number'); + }); + + it('should fail gracefully for lock when vault is not initialized', () => { + const { stderr, exitCode } = run(['lock']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('not initialized'); + }); +}); + +describe('output formatting helpers', () => { + it('should show description in help', () => { + const { stdout } = run(['--help']); + expect(stdout).toContain('Encrypted file vault'); + }); +}); From 32228a19a2a8ac4949e99d193920eda652523827 Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 13:46:04 -0700 Subject: [PATCH 11/23] mcp: MCP server with vault file operation tools - Tools: vault_read_file, vault_create_file, vault_list_dir, vault_grep_search, vault_file_search - Unlock-gated access: content tools require active session - Metadata tools (list, file_search) work while locked - Clear error messages guiding user to run vault unlock - Output sanitization for terminal safety - Unit tests for all tool behaviors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/mcp/server.ts | 190 +++++++++++++++++++++++++++++++++ test/unit/mcp.test.ts | 243 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 433 insertions(+) create mode 100644 src/mcp/server.ts create mode 100644 test/unit/mcp.test.ts diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..e6dec1d --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,190 @@ +import { z } from 'zod'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { VaultEngine } from '../vault/index.js'; +import { SessionManager } from '../session/index.js'; +import { sanitizeOutput } from '../security/index.js'; +import { + VaultLockedError, + SessionExpiredError, + type FileMetadata, +} from '../types.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +const LOCKED_MESSAGE = + 'Vault is locked. Run `vault unlock` in your terminal to unlock.'; + +function getMasterKey(session: SessionManager): string { + try { + return session.readSession(); + } catch (err) { + if (err instanceof VaultLockedError || err instanceof SessionExpiredError) { + throw new Error(LOCKED_MESSAGE, { cause: err }); + } + throw err; + } +} + +function errorResult(message: string): CallToolResult { + return { + content: [{ type: 'text', text: `Error: ${message}` }], + isError: true, + }; +} + +function formatMetadata(files: FileMetadata[]): string { + if (files.length === 0) return 'No files found.'; + + return files + .map((f) => { + const tags = f.tags.length > 0 ? ` [${f.tags.join(', ')}]` : ''; + return `${f.vaultPath} (${f.size} bytes, modified ${f.modifiedAt.toISOString()})${tags}`; + }) + .join('\n'); +} + +export function createVaultMcpServer( + engine: VaultEngine, + session: SessionManager, +): McpServer { + const server = new McpServer( + { name: 'workspace-vault', version: '0.1.0' }, + { capabilities: { tools: {} } }, + ); + + // ── vault_read_file ────────────────────────────────────────────────── + + server.tool( + 'vault_read_file', + 'Read a file from the encrypted vault. Requires the vault to be unlocked.', + { path: z.string().describe('Vault path of the file to read') }, + async ({ path: vaultPath }): Promise => { + try { + const masterKey = getMasterKey(session); + const content = await engine.readFile(vaultPath, masterKey); + return { + content: [ + { type: 'text', text: sanitizeOutput(content.toString('utf-8')) }, + ], + }; + } catch (err) { + return errorResult((err as Error).message); + } + }, + ); + + // ── vault_create_file ──────────────────────────────────────────────── + + server.tool( + 'vault_create_file', + 'Create a new encrypted file in the vault. Requires the vault to be unlocked.', + { + path: z.string().describe('Vault path for the new file'), + content: z.string().describe('File content to encrypt and store'), + tags: z + .array(z.string()) + .optional() + .describe('Optional tags for the file'), + }, + async ({ path: vaultPath, content, tags }): Promise => { + try { + const masterKey = getMasterKey(session); + const meta = await engine.writeFile( + vaultPath, + Buffer.from(content, 'utf-8'), + masterKey, + tags, + ); + return { + content: [ + { + type: 'text', + text: sanitizeOutput( + `Created ${meta.vaultPath} (${meta.size} bytes, id: ${meta.id})`, + ), + }, + ], + }; + } catch (err) { + return errorResult((err as Error).message); + } + }, + ); + + // ── vault_list_dir ─────────────────────────────────────────────────── + + server.tool( + 'vault_list_dir', + 'List files in the vault. Shows metadata (names, sizes, dates, tags). Works even when the vault is locked.', + { + path: z + .string() + .optional() + .describe('Optional directory prefix to filter by'), + }, + ({ path: dirPath }): CallToolResult => { + try { + const files = engine.listFiles(dirPath); + return { + content: [ + { type: 'text', text: sanitizeOutput(formatMetadata(files)) }, + ], + }; + } catch (err) { + return errorResult((err as Error).message); + } + }, + ); + + // ── vault_grep_search ──────────────────────────────────────────────── + + server.tool( + 'vault_grep_search', + 'Search file contents in the vault using a pattern. Requires the vault to be unlocked.', + { pattern: z.string().describe('Search pattern (regex supported)') }, + async ({ pattern }): Promise => { + try { + const masterKey = getMasterKey(session); + const results = await engine.grepFiles(pattern, masterKey); + if (results.length === 0) { + return { content: [{ type: 'text', text: 'No matches found.' }] }; + } + const text = results + .map( + (r) => + `${r.vaultPath}:${r.lineNumber}: ${sanitizeOutput(r.line)}`, + ) + .join('\n'); + return { content: [{ type: 'text', text }] }; + } catch (err) { + return errorResult((err as Error).message); + } + }, + ); + + // ── vault_file_search ──────────────────────────────────────────────── + + server.tool( + 'vault_file_search', + 'Search for files by name or tag in the vault. Works even when the vault is locked.', + { query: z.string().describe('Search query for filenames or tags') }, + ({ query }): CallToolResult => { + try { + const results = engine.searchFiles(query); + if (results.length === 0) { + return { content: [{ type: 'text', text: 'No matches found.' }] }; + } + const text = results + .map( + (r) => + `${r.vaultPath} (${r.matchType}: ${sanitizeOutput(r.matchedValue)})`, + ) + .join('\n'); + return { content: [{ type: 'text', text }] }; + } catch (err) { + return errorResult((err as Error).message); + } + }, + ); + + return server; +} diff --git a/test/unit/mcp.test.ts b/test/unit/mcp.test.ts new file mode 100644 index 0000000..9d20c46 --- /dev/null +++ b/test/unit/mcp.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { VaultEngine } from '../../src/vault/engine.js'; +import { SessionManager } from '../../src/session/manager.js'; +import { createVaultMcpServer } from '../../src/mcp/server.js'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-test-')); +} + +function cleanup(dir: string): void { + fs.rmSync(dir, { recursive: true, force: true }); +} + +/** + * Call a tool on the McpServer by accessing its internal registered tools. + * This avoids needing a full MCP transport for unit tests. + */ +async function callTool( + server: McpServer, + name: string, + args: Record = {}, +): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }> { + // Access internal registered tools via the server's underlying Server + // We use the McpServer's tool registry via private _registeredTools + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const registeredTools = (server as any)._registeredTools; + const tool = registeredTools[name]; + if (!tool) { + throw new Error(`Tool ${name} not found`); + } + + // The handler is the callback function; call it with args and a minimal extra object + const extra = { signal: new AbortController().signal }; + const result = await tool.handler(args, extra); + return result as { content: Array<{ type: string; text: string }>; isError?: boolean }; +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('MCP Server', () => { + let tmpDir: string; + let vaultDir: string; + let sessionPath: string; + let engine: VaultEngine; + let session: SessionManager; + let server: McpServer; + let masterKey: string; + + const TEST_PASSPHRASE = 'test-passphrase-long-enough'; + + beforeEach(async () => { + tmpDir = makeTempDir(); + vaultDir = path.join(tmpDir, 'vault'); + sessionPath = path.join(tmpDir, 'session'); + + // Initialize a vault with a test passphrase + engine = await VaultEngine.init(vaultDir, TEST_PASSPHRASE); + masterKey = await engine.unlock(TEST_PASSPHRASE); + + session = new SessionManager(sessionPath); + server = createVaultMcpServer(engine, session); + + // Write a test file into the vault + await engine.writeFile( + 'docs/readme.md', + Buffer.from('# Hello World\nThis is a test file.'), + masterKey, + ['doc', 'readme'], + ); + await engine.writeFile( + 'secrets/api-key.txt', + Buffer.from('sk-secret-key-12345'), + masterKey, + ['secret'], + ); + }); + + afterEach(() => { + engine.close(); + cleanup(tmpDir); + }); + + // ── Helper to unlock/lock ────────────────────────────────────────── + + function unlockVault(): void { + session.writeSession(masterKey, 30); + } + + function lockVault(): void { + session.clearSession(); + } + + // ── vault_list_dir ───────────────────────────────────────────────── + + describe('vault_list_dir', () => { + it('lists files while locked (no session file)', async () => { + lockVault(); + const result = await callTool(server, 'vault_list_dir'); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain('docs/readme.md'); + expect(result.content[0].text).toContain('secrets/api-key.txt'); + }); + + it('lists files while unlocked', async () => { + unlockVault(); + const result = await callTool(server, 'vault_list_dir'); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain('docs/readme.md'); + expect(result.content[0].text).toContain('secrets/api-key.txt'); + }); + + it('filters by directory prefix', async () => { + const result = await callTool(server, 'vault_list_dir', { + path: 'docs', + }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain('docs/readme.md'); + expect(result.content[0].text).not.toContain('secrets/api-key.txt'); + }); + }); + + // ── vault_file_search ────────────────────────────────────────────── + + describe('vault_file_search', () => { + it('searches filenames while locked', async () => { + lockVault(); + const result = await callTool(server, 'vault_file_search', { + query: 'readme', + }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain('docs/readme.md'); + }); + + it('returns no matches for unknown query', async () => { + const result = await callTool(server, 'vault_file_search', { + query: 'nonexistent', + }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain('No matches found.'); + }); + }); + + // ── vault_read_file ──────────────────────────────────────────────── + + describe('vault_read_file', () => { + it('reads file content when unlocked', async () => { + unlockVault(); + const result = await callTool(server, 'vault_read_file', { + path: 'docs/readme.md', + }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain('# Hello World'); + expect(result.content[0].text).toContain('This is a test file.'); + }); + + it('returns locked error when vault is locked', async () => { + lockVault(); + const result = await callTool(server, 'vault_read_file', { + path: 'docs/readme.md', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('vault unlock'); + }); + + it('returns not found error for missing file', async () => { + unlockVault(); + const result = await callTool(server, 'vault_read_file', { + path: 'nonexistent.txt', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('not found'); + }); + }); + + // ── vault_create_file ────────────────────────────────────────────── + + describe('vault_create_file', () => { + it('creates file when unlocked', async () => { + unlockVault(); + const result = await callTool(server, 'vault_create_file', { + path: 'notes/todo.md', + content: '- Buy groceries', + tags: ['note'], + }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain('Created notes/todo.md'); + + // Verify the file is readable + const readResult = await callTool(server, 'vault_read_file', { + path: 'notes/todo.md', + }); + expect(readResult.content[0].text).toContain('Buy groceries'); + }); + + it('returns locked error when vault is locked', async () => { + lockVault(); + const result = await callTool(server, 'vault_create_file', { + path: 'notes/todo.md', + content: '- Buy groceries', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('vault unlock'); + }); + }); + + // ── vault_grep_search ────────────────────────────────────────────── + + describe('vault_grep_search', () => { + it('searches content when unlocked', async () => { + unlockVault(); + const result = await callTool(server, 'vault_grep_search', { + pattern: 'Hello', + }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain('docs/readme.md'); + expect(result.content[0].text).toContain('Hello World'); + }); + + it('returns locked error when vault is locked', async () => { + lockVault(); + const result = await callTool(server, 'vault_grep_search', { + pattern: 'Hello', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('vault unlock'); + }); + + it('returns no matches message when pattern not found', async () => { + unlockVault(); + const result = await callTool(server, 'vault_grep_search', { + pattern: 'zzz_nonexistent_pattern', + }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain('No matches found.'); + }); + }); +}); From f7bb9cadba428f385496cb1e60994ddb7c654b5d Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 13:49:23 -0700 Subject: [PATCH 12/23] fix: wire up MCP server command and barrel export Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/cli/commands.ts | 17 ++++++++++++++--- src/cli/index.ts | 4 ++-- src/mcp/index.ts | 2 +- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 209a4d6..2fc5b74 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -401,7 +401,18 @@ export function auditCommand(options: { } } -export function mcpCommand(): void { - process.stderr.write('MCP server not yet implemented.\n'); - process.exit(1); +export async function mcpCommand(): Promise { + const config = getConfig(); + requireInit(config); + const engine = getEngine(config); + const session = getSession(config); + + const { createVaultMcpServer } = await import('../mcp/index.js'); + const { StdioServerTransport } = await import( + '@modelcontextprotocol/sdk/server/stdio.js' + ); + + const server = createVaultMcpServer(engine, session); + const transport = new StdioServerTransport(); + await server.connect(transport); } diff --git a/src/cli/index.ts b/src/cli/index.ts index 7647f3e..bb19d2f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -150,8 +150,8 @@ program program .command('mcp') .description('Start the MCP server') - .action(() => { - mcpCommand(); + .action(async () => { + await mcpCommand(); }); // Global error handling diff --git a/src/mcp/index.ts b/src/mcp/index.ts index cb0ff5c..c28c50f 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -1 +1 @@ -export {}; +export { createVaultMcpServer } from './server.js'; From efaf9425cee917c8e420e8c79e12196008896afe Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 17:39:05 -0700 Subject: [PATCH 13/23] test: integration tests for full vault lifecycle Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/integration/vault.integration.test.ts | 468 +++++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 test/integration/vault.integration.test.ts diff --git a/test/integration/vault.integration.test.ts b/test/integration/vault.integration.test.ts new file mode 100644 index 0000000..5d4ae99 --- /dev/null +++ b/test/integration/vault.integration.test.ts @@ -0,0 +1,468 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { VaultEngine } from '../../src/vault/engine.js'; +import { SessionManager } from '../../src/session/manager.js'; +import { ConfigManager } from '../../src/config/index.js'; +import { createVaultMcpServer } from '../../src/mcp/server.js'; +import { + OperationType, + VaultLockedError, + FileNotFoundError, + FileAlreadyExistsError, + InvalidKeyError, + LastKeyError, + PathTraversalError, + SessionExpiredError, +} from '../../src/types.js'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function makeTempDir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function cleanup(dir: string): void { + fs.rmSync(dir, { recursive: true, force: true }); +} + +const TEST_PASSPHRASE = 'integration-test-passphrase-1'; +const TEST_PASSPHRASE_2 = 'integration-test-passphrase-2'; + +async function callTool( + server: McpServer, + name: string, + args: Record = {}, +): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }> { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const registeredTools = (server as any)._registeredTools; + const tool = registeredTools[name]; + if (!tool) throw new Error(`Tool ${name} not found`); + const extra = { signal: new AbortController().signal }; + const result = await tool.handler(args, extra); + return result as { content: Array<{ type: string; text: string }>; isError?: boolean }; +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('Integration: Full vault lifecycle', { timeout: 60_000 }, () => { + const dirs: string[] = []; + const engines: VaultEngine[] = []; + + afterEach(() => { + for (const e of engines) { + try { e.close(); } catch { /* already closed */ } + } + engines.length = 0; + for (const d of dirs) cleanup(d); + dirs.length = 0; + }); + + function trackDir(): string { + const d = makeTempDir('vault-integ-'); + dirs.push(d); + return d; + } + + function trackEngine(e: VaultEngine): VaultEngine { + engines.push(e); + return e; + } + + // ── 1. Full lifecycle ────────────────────────────────────────────── + + it('init → unlock → write → read → lock → verify locked → unlock → read', async () => { + const tmpDir = trackDir(); + const vaultDir = path.join(tmpDir, 'vault'); + + // Init + const engine = trackEngine(await VaultEngine.init(vaultDir, TEST_PASSPHRASE)); + + // Unlock + const masterKey = await engine.unlock(TEST_PASSPHRASE); + expect(masterKey).toBeTruthy(); + expect(typeof masterKey).toBe('string'); + + // Write + const content = Buffer.from('Secret document content'); + const meta = await engine.writeFile('docs/secret.txt', content, masterKey, ['private']); + expect(meta.vaultPath).toBe('docs/secret.txt'); + expect(meta.size).toBe(content.length); + expect(meta.tags).toEqual(['private']); + + // Read + const readBack = await engine.readFile('docs/secret.txt', masterKey); + expect(readBack.toString('utf-8')).toBe('Secret document content'); + + // Session lock/unlock cycle + const sessionPath = path.join(tmpDir, 'session'); + const session = new SessionManager(sessionPath); + + session.writeSession(masterKey, 30); + expect(session.isUnlocked()).toBe(true); + expect(session.readSession()).toBe(masterKey); + + session.clearSession(); + expect(session.isUnlocked()).toBe(false); + expect(() => session.readSession()).toThrow(VaultLockedError); + + // Unlock again and verify readable + const masterKey2 = await engine.unlock(TEST_PASSPHRASE); + expect(masterKey2).toBe(masterKey); + const readBack2 = await engine.readFile('docs/secret.txt', masterKey2); + expect(readBack2.toString('utf-8')).toBe('Secret document content'); + }); + + // ── 2. Multi-file operations ─────────────────────────────────────── + + it('write multiple files → list → search by name → search by tag → grep content', async () => { + const tmpDir = trackDir(); + const vaultDir = path.join(tmpDir, 'vault'); + const engine = trackEngine(await VaultEngine.init(vaultDir, TEST_PASSPHRASE)); + const masterKey = await engine.unlock(TEST_PASSPHRASE); + + // Write multiple files + await engine.writeFile('src/main.ts', Buffer.from('export function main() { return 42; }'), masterKey, ['typescript', 'entry']); + await engine.writeFile('src/utils.ts', Buffer.from('export function add(a: number, b: number) { return a + b; }'), masterKey, ['typescript', 'utils']); + await engine.writeFile('docs/readme.md', Buffer.from('# Project\nThis is the readme file.'), masterKey, ['documentation']); + await engine.writeFile('config.json', Buffer.from('{"port": 3000}'), masterKey, ['config']); + + // List all + const allFiles = engine.listFiles(); + expect(allFiles).toHaveLength(4); + + // List by prefix + const srcFiles = engine.listFiles('src'); + expect(srcFiles).toHaveLength(2); + expect(srcFiles.map(f => f.vaultPath)).toContain('src/main.ts'); + expect(srcFiles.map(f => f.vaultPath)).toContain('src/utils.ts'); + + // Search by name + const nameResults = engine.searchFiles('readme'); + expect(nameResults.length).toBeGreaterThanOrEqual(1); + expect(nameResults[0].vaultPath).toBe('docs/readme.md'); + + // Search by tag (tags stored as JSON, search uses LIKE on tags column) + const tagResults = engine.searchFiles('typescript'); + expect(tagResults.length).toBeGreaterThanOrEqual(2); + + // Grep content + const grepResults = await engine.grepFiles('function', masterKey); + expect(grepResults.length).toBeGreaterThanOrEqual(2); + const grepPaths = grepResults.map(r => r.vaultPath); + expect(grepPaths).toContain('src/main.ts'); + expect(grepPaths).toContain('src/utils.ts'); + + // Grep with specific pattern + const grepPort = await engine.grepFiles('3000', masterKey); + expect(grepPort).toHaveLength(1); + expect(grepPort[0].vaultPath).toBe('config.json'); + expect(grepPort[0].line).toContain('3000'); + }); + + // ── 3. Key management ───────────────────────────────────────────── + + it('add second key → unlock with it → revoke first → verify', async () => { + const tmpDir = trackDir(); + const vaultDir = path.join(tmpDir, 'vault'); + const engine = trackEngine(await VaultEngine.init(vaultDir, TEST_PASSPHRASE)); + const masterKey = await engine.unlock(TEST_PASSPHRASE); + + // List keys: should have 1 + const initialKeys = engine.listKeys(); + expect(initialKeys).toHaveLength(1); + expect(initialKeys[0].label).toBe('primary passphrase'); + const firstKeyId = initialKeys[0].id; + + // Add second key + const newKey = await engine.addKey(TEST_PASSPHRASE_2, 'backup key', masterKey); + expect(newKey.label).toBe('backup key'); + expect(engine.listKeys()).toHaveLength(2); + + // Unlock with second key + const masterKey2 = await engine.unlock(TEST_PASSPHRASE_2); + expect(masterKey2).toBe(masterKey); + + // Revoke first key + engine.revokeKey(firstKeyId); + expect(engine.listKeys()).toHaveLength(1); + + // First passphrase no longer works + await expect(engine.unlock(TEST_PASSPHRASE)).rejects.toThrow(InvalidKeyError); + + // Second passphrase still works + const masterKey3 = await engine.unlock(TEST_PASSPHRASE_2); + expect(masterKey3).toBe(masterKey); + + // Can't revoke the last key + const remainingKeyId = engine.listKeys()[0].id; + expect(() => engine.revokeKey(remainingKeyId)).toThrow(LastKeyError); + }); + + // ── 4. Session expiry ───────────────────────────────────────────── + + it('session expires after TTL', async () => { + const tmpDir = trackDir(); + const sessionPath = path.join(tmpDir, 'session'); + const session = new SessionManager(sessionPath); + + // Write session with 30-min TTL (works normally) + session.writeSession('fake-master-key-hex', 30); + expect(session.isUnlocked()).toBe(true); + expect(session.readSession()).toBe('fake-master-key-hex'); + + const info = session.getSessionInfo(); + expect(info).not.toBeNull(); + expect(info!.expiresAt.getTime()).toBeGreaterThan(Date.now()); + + // Manipulate session file to be expired + const raw = fs.readFileSync(sessionPath, 'utf-8'); + const data = JSON.parse(raw); + data.expiresAt = new Date(Date.now() - 1000).toISOString(); + fs.writeFileSync(sessionPath, JSON.stringify(data), 'utf-8'); + + // Should now throw SessionExpiredError + expect(() => session.readSession()).toThrow(SessionExpiredError); + expect(session.isUnlocked()).toBe(false); + + // Session file should be cleaned up + expect(fs.existsSync(sessionPath)).toBe(false); + }); + + // ── 5. MCP server integration ───────────────────────────────────── + + it('MCP server tools work end-to-end', async () => { + const tmpDir = trackDir(); + const vaultDir = path.join(tmpDir, 'vault'); + const sessionPath = path.join(tmpDir, 'session'); + + const engine = trackEngine(await VaultEngine.init(vaultDir, TEST_PASSPHRASE)); + const masterKey = await engine.unlock(TEST_PASSPHRASE); + const session = new SessionManager(sessionPath); + const server = createVaultMcpServer(engine, session); + + // ── Locked: vault_list_dir and vault_file_search work + const listLocked = await callTool(server, 'vault_list_dir'); + expect(listLocked.isError).toBeFalsy(); + + // ── Locked: read/create/grep fail + const readLocked = await callTool(server, 'vault_read_file', { path: 'test.txt' }); + expect(readLocked.isError).toBe(true); + expect(readLocked.content[0].text).toContain('vault unlock'); + + const createLocked = await callTool(server, 'vault_create_file', { path: 'x.txt', content: 'hi' }); + expect(createLocked.isError).toBe(true); + + const grepLocked = await callTool(server, 'vault_grep_search', { pattern: 'test' }); + expect(grepLocked.isError).toBe(true); + + // ── Unlock + session.writeSession(masterKey, 30); + + // vault_create_file + const createResult = await callTool(server, 'vault_create_file', { + path: 'notes/todo.md', + content: '- Buy milk\n- Write tests', + tags: ['note', 'todo'], + }); + expect(createResult.isError).toBeFalsy(); + expect(createResult.content[0].text).toContain('Created notes/todo.md'); + + // vault_read_file + const readResult = await callTool(server, 'vault_read_file', { path: 'notes/todo.md' }); + expect(readResult.isError).toBeFalsy(); + expect(readResult.content[0].text).toContain('Buy milk'); + + // vault_list_dir + const listResult = await callTool(server, 'vault_list_dir'); + expect(listResult.isError).toBeFalsy(); + expect(listResult.content[0].text).toContain('notes/todo.md'); + + // vault_file_search + const searchResult = await callTool(server, 'vault_file_search', { query: 'todo' }); + expect(searchResult.isError).toBeFalsy(); + expect(searchResult.content[0].text).toContain('notes/todo.md'); + + // vault_grep_search + const grepResult = await callTool(server, 'vault_grep_search', { pattern: 'milk' }); + expect(grepResult.isError).toBeFalsy(); + expect(grepResult.content[0].text).toContain('notes/todo.md'); + expect(grepResult.content[0].text).toContain('Buy milk'); + }); + + // ── 6. Audit log completeness ───────────────────────────────────── + + it('audit log records all operations', async () => { + const tmpDir = trackDir(); + const vaultDir = path.join(tmpDir, 'vault'); + + const engine = trackEngine(await VaultEngine.init(vaultDir, TEST_PASSPHRASE)); + const audit = engine.getAuditLogger(); + + // Init event already logged + const initEvents = audit.getEvents({ operation: OperationType.INIT }); + expect(initEvents).toHaveLength(1); + expect(initEvents[0].success).toBe(true); + + // Unlock + const masterKey = await engine.unlock(TEST_PASSPHRASE); + const unlockEvents = audit.getEvents({ operation: OperationType.UNLOCK }); + expect(unlockEvents.length).toBeGreaterThanOrEqual(1); + expect(unlockEvents[0].success).toBe(true); + + // Write + await engine.writeFile('test.txt', Buffer.from('hello'), masterKey); + const writeEvents = audit.getEvents({ operation: OperationType.WRITE }); + expect(writeEvents).toHaveLength(1); + expect(writeEvents[0].targetPath).toBe('test.txt'); + + // Read + await engine.readFile('test.txt', masterKey); + const readEvents = audit.getEvents({ operation: OperationType.READ }); + expect(readEvents).toHaveLength(1); + + // List + engine.listFiles(); + const listEvents = audit.getEvents({ operation: OperationType.LIST }); + expect(listEvents).toHaveLength(1); + + // Search + engine.searchFiles('test'); + const searchEvents = audit.getEvents({ operation: OperationType.SEARCH }); + expect(searchEvents).toHaveLength(1); + + // Grep + await engine.grepFiles('hello', masterKey); + const grepEvents = audit.getEvents({ operation: OperationType.GREP }); + expect(grepEvents).toHaveLength(1); + + // Delete + await engine.deleteFile('test.txt'); + const deleteEvents = audit.getEvents({ operation: OperationType.DELETE }); + expect(deleteEvents).toHaveLength(1); + expect(deleteEvents[0].targetPath).toBe('test.txt'); + + // Key add + const newKey = await engine.addKey(TEST_PASSPHRASE_2, 'second', masterKey); + const keyAddEvents = audit.getEvents({ operation: OperationType.KEY_ADD }); + expect(keyAddEvents).toHaveLength(1); + expect(keyAddEvents[0].keyId).toBe(newKey.id); + + // Key revoke + engine.revokeKey(newKey.id); + const keyRevokeEvents = audit.getEvents({ operation: OperationType.KEY_REVOKE }); + expect(keyRevokeEvents).toHaveLength(1); + expect(keyRevokeEvents[0].keyId).toBe(newKey.id); + + // Total events + const total = audit.getEventCount(); + expect(total).toBeGreaterThanOrEqual(10); + }); + + // ── 7. Security: path traversal ─────────────────────────────────── + + it('rejects path traversal attempts', async () => { + const tmpDir = trackDir(); + const vaultDir = path.join(tmpDir, 'vault'); + const engine = trackEngine(await VaultEngine.init(vaultDir, TEST_PASSPHRASE)); + const masterKey = await engine.unlock(TEST_PASSPHRASE); + + const maliciousPaths = [ + '../outside.txt', + '../../etc/passwd', + 'docs/../../outside.txt', + path.resolve('/etc/passwd'), + 'file\0name.txt', + ]; + + for (const p of maliciousPaths) { + await expect(engine.writeFile(p, Buffer.from('bad'), masterKey)).rejects.toThrow(); + await expect(engine.readFile(p, masterKey)).rejects.toThrow(); + } + }); + + // ── 8. Delete + re-create ───────────────────────────────────────── + + it('delete file then re-create at same path', async () => { + const tmpDir = trackDir(); + const vaultDir = path.join(tmpDir, 'vault'); + const engine = trackEngine(await VaultEngine.init(vaultDir, TEST_PASSPHRASE)); + const masterKey = await engine.unlock(TEST_PASSPHRASE); + + // Write + await engine.writeFile('data.txt', Buffer.from('version 1'), masterKey); + const v1 = await engine.readFile('data.txt', masterKey); + expect(v1.toString('utf-8')).toBe('version 1'); + + // Delete + await engine.deleteFile('data.txt'); + await expect(engine.readFile('data.txt', masterKey)).rejects.toThrow(FileNotFoundError); + expect(engine.listFiles()).toHaveLength(0); + + // Re-create + const meta2 = await engine.writeFile('data.txt', Buffer.from('version 2'), masterKey, ['v2']); + expect(meta2.tags).toEqual(['v2']); + const v2 = await engine.readFile('data.txt', masterKey); + expect(v2.toString('utf-8')).toBe('version 2'); + }); + + // ── 9. Overwrite (duplicate path) ───────────────────────────────── + + it('writing to existing path throws FileAlreadyExistsError', async () => { + const tmpDir = trackDir(); + const vaultDir = path.join(tmpDir, 'vault'); + const engine = trackEngine(await VaultEngine.init(vaultDir, TEST_PASSPHRASE)); + const masterKey = await engine.unlock(TEST_PASSPHRASE); + + await engine.writeFile('dup.txt', Buffer.from('first'), masterKey); + await expect( + engine.writeFile('dup.txt', Buffer.from('second'), masterKey), + ).rejects.toThrow(FileAlreadyExistsError); + + // Original content preserved + const content = await engine.readFile('dup.txt', masterKey); + expect(content.toString('utf-8')).toBe('first'); + }); + + // ── 10. Empty vault ─────────────────────────────────────────────── + + it('list/search/grep on empty vault returns empty results', async () => { + const tmpDir = trackDir(); + const vaultDir = path.join(tmpDir, 'vault'); + const engine = trackEngine(await VaultEngine.init(vaultDir, TEST_PASSPHRASE)); + const masterKey = await engine.unlock(TEST_PASSPHRASE); + + expect(engine.listFiles()).toEqual([]); + expect(engine.listFiles('nonexistent')).toEqual([]); + expect(engine.searchFiles('anything')).toEqual([]); + + const grepResults = await engine.grepFiles('pattern', masterKey); + expect(grepResults).toEqual([]); + + // Status reflects empty vault + const status = engine.getStatus(true); + expect(status.initialized).toBe(true); + expect(status.locked).toBe(false); + expect(status.fileCount).toBe(0); + expect(status.keyCount).toBe(1); + }); + + // ── ConfigManager integration ───────────────────────────────────── + + it('ConfigManager works with custom config dir', () => { + const tmpDir = trackDir(); + const configDir = path.join(tmpDir, 'config'); + const vaultDir = path.join(tmpDir, 'vault'); + + const config = new ConfigManager(configDir); + expect(config.isInitialized()).toBe(false); + + const savedConfig = config.initialize(vaultDir); + expect(savedConfig.vaultPath).toBe(path.resolve(vaultDir)); + expect(config.isInitialized()).toBe(true); + expect(config.getVaultPath()).toBe(path.resolve(vaultDir)); + expect(config.getSessionPath()).toContain('session'); + }); +}); From 9a4729a9cff58562cf4409eb39ff1f006dd4325f Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 19:58:16 -0700 Subject: [PATCH 14/23] docs: README, quickstart guide, CLI and MCP reference - Rewrote README.md with quickstart, CLI overview, MCP tools table - Created docs/quickstart.md with full setup walkthrough - Created docs/cli.md with all commands and options - Created docs/mcp.md with tool reference and locked/unlocked behavior - Fixed init command to suggest 'vault mcp' instead of 'npx' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 125 +++++++++++++++++++++++++- docs/cli.md | 214 ++++++++++++++++++++++++++++++++++++++++++++ docs/mcp.md | 172 +++++++++++++++++++++++++++++++++++ docs/quickstart.md | 143 +++++++++++++++++++++++++++++ src/cli/commands.ts | 4 +- 5 files changed, 654 insertions(+), 4 deletions(-) create mode 100644 docs/cli.md create mode 100644 docs/mcp.md create mode 100644 docs/quickstart.md diff --git a/README.md b/README.md index 242f6ce..869a1d0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,123 @@ -# workspace-vault -A secure vault to store private files that GitHub custom agents can use to read/write private content separately from the current workspace +# Workspace Vault + +An encrypted file vault with CLI and MCP server that gives AI agents controlled access to your private files. + +Files are encrypted at rest using [age](https://age-encryption.org/). You control when the vault is unlocked. Your agent works with files through standard operations — read, write, search, list — and when the session ends, everything locks again. + +## Quickstart + +```bash +# Install +npm install -g workspace-vault + +# Initialize a vault (you'll set a passphrase) +vault init + +# Store a file +vault write contracts/nda.pdf --from ~/Documents/nda.pdf + +# Unlock so your agent can access files +vault unlock + +# Check status +vault status +``` + +### Connect to your AI agent + +After `vault init`, add this to your MCP client config: + +**VS Code / Copilot** (`.vscode/mcp.json`): +```json +{ + "servers": { + "workspace-vault": { + "command": "vault", + "args": ["mcp"] + } + } +} +``` + +**Claude Desktop** (`claude_desktop_config.json`): +```json +{ + "mcpServers": { + "workspace-vault": { + "command": "vault", + "args": ["mcp"] + } + } +} +``` + +That's it. Your agent can now read, write, search, and list vault files — as long as you've unlocked the vault. + +## How it works + +1. **You** initialize the vault and set a passphrase +2. **You** store files into the vault (they're encrypted immediately) +3. **You** unlock the vault when you want your agent to have access +4. **Your agent** reads, writes, and searches files through MCP tools +5. The vault **auto-locks** after 30 minutes (configurable) or when you run `vault lock` + +The agent never sees the passphrase. It never self-authorizes. All access is audited. + +## Documentation + +- **[Quickstart Guide](docs/quickstart.md)** — full setup walkthrough +- **[CLI Reference](docs/cli.md)** — all commands and options +- **[MCP Tools Reference](docs/mcp.md)** — tools available to AI agents +- **[Security Model](spec/security.md)** — encryption, threat model, design decisions + +## CLI overview + +``` +vault init [path] Initialize a new vault +vault unlock Unlock the vault (passphrase prompt) +vault lock Lock the vault +vault status Show vault status + +vault write Write a file (--from or stdin) +vault read Read and decrypt a file +vault delete Delete a file +vault list [path] List files (works while locked) +vault search Search by filename or tag +vault grep Search file contents (requires unlock) + +vault key add Add a new passphrase key +vault key list List authorized keys +vault key revoke Revoke a key + +vault audit View the audit log +vault mcp Start the MCP server +``` + +## MCP tools + +| Tool | Description | Requires unlock | +|------|-------------|:---:| +| `vault_read_file` | Read and decrypt a file | Yes | +| `vault_create_file` | Create an encrypted file | Yes | +| `vault_grep_search` | Search file contents by pattern | Yes | +| `vault_list_dir` | List files and metadata | No | +| `vault_file_search` | Search by filename or tag | No | + +## Security + +- Files are encrypted at rest with [age](https://age-encryption.org/) — no custom cryptography +- The master key exists in memory only during active sessions +- Multi-key support: add backup keys, revoke compromised ones without re-encrypting +- All operations are recorded in an audit log (no content is ever logged) +- Path traversal and injection attacks are blocked at the boundary + +See [spec/security.md](spec/security.md) for the full security model. + +## Requirements + +- Node.js 18+ +- macOS, Linux, or Windows + +## License + +MIT diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..56b9b5e --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,214 @@ +# CLI Reference + +## Global options + +``` +vault --version Show version number +vault --help Show help +vault --help Show help for a specific command +``` + +--- + +## Vault management + +### `vault init [path]` + +Initialize a new encrypted vault. + +```bash +vault init # Creates vault at ~/.vault +vault init ~/my-vault # Creates vault at custom path +``` + +Prompts for a passphrase (with confirmation). Creates the vault directory, generates the master key, and stores your config. + +After init, prints an MCP config snippet you can paste into your agent's settings. + +### `vault unlock` + +Unlock the vault for the current session. + +```bash +vault unlock +``` + +Prompts for your passphrase. On success, creates a session that lasts 30 minutes. The MCP server can access file content while the session is active. + +If the vault is already unlocked, this is a no-op. + +### `vault lock` + +Lock the vault immediately. + +```bash +vault lock +``` + +Clears the session. The MCP server will return "vault is locked" errors until you unlock again. + +### `vault status` + +Show vault status. + +```bash +vault status +``` + +Output includes: +- Lock state (locked/unlocked) +- Number of stored files +- Number of authorized keys +- Session expiry time (if unlocked) + +--- + +## File operations + +### `vault write ` + +Write a file to the vault. + +```bash +# From a local file +vault write notes/todo.md --from ~/todo.md + +# With tags +vault write medical/labs.pdf --from ~/labs.pdf --tag medical --tag 2025 + +# From stdin +echo "secret content" | vault write secrets/api-key.txt +cat report.pdf | vault write reports/q4.pdf +``` + +**Options:** +- `--from ` — read content from a local file (otherwise reads stdin) +- `--tag ` — one or more tags for the file + +Requires the vault to be unlocked. + +### `vault read ` + +Read and decrypt a file from the vault. + +```bash +vault read contracts/nda.pdf +vault read contracts/nda.pdf > ~/Desktop/nda.pdf +``` + +Outputs decrypted content to stdout. Requires unlock. + +### `vault delete ` + +Delete a file from the vault. + +```bash +vault delete old/draft.md +``` + +Removes both the encrypted blob and metadata. Requires unlock. + +### `vault list [vault-path]` + +List files in the vault. + +```bash +vault list # List all files +vault list contracts/ # List files under contracts/ +``` + +Shows path, size, modified date, and tags. **Works while locked** — only shows metadata, not content. + +### `vault search ` + +Search for files by filename or tag. + +```bash +vault search nda +vault search medical +``` + +Returns matching files with the match type (filename or tag). **Works while locked.** + +### `vault grep ` + +Search file contents using a regex pattern. + +```bash +vault grep "Acme Corp" +vault grep "API_KEY=.*" +``` + +Returns matching lines with file path and line number. Requires unlock (decrypts files to search). + +--- + +## Key management + +### `vault key add` + +Add a new passphrase key to the vault. + +```bash +vault key add +``` + +Prompts for: +1. A new passphrase (with confirmation) +2. A label for the key (e.g., "backup", "work-laptop") + +The new key can independently unlock the vault. Requires the vault to be currently unlocked. + +### `vault key list` + +List all authorized keys. + +```bash +vault key list +``` + +Shows key ID, label, and creation date. + +### `vault key revoke ` + +Revoke an authorized key. + +```bash +vault key revoke a1b2c3d4-... +``` + +Removes the key's ability to unlock the vault. Cannot revoke the last remaining key. + +--- + +## Audit + +### `vault audit` + +View the audit log. + +```bash +vault audit # Show all events +vault audit --tail 20 # Show last 20 events +vault audit --operation read # Filter by operation type +``` + +**Options:** +- `--tail ` — show only the last N events +- `--operation ` — filter by operation type (`read`, `write`, `delete`, `search`, `grep`, `list`, `unlock`, `lock`, `init`, `key_add`, `key_revoke`) + +Shows timestamp, operation, target path, and success/failure status. **File content is never logged.** + +--- + +## MCP server + +### `vault mcp` + +Start the MCP server over stdio. + +```bash +vault mcp +``` + +This is typically not run directly — it's invoked by your MCP client (VS Code, Claude Desktop, etc.) as configured in the MCP settings. See the [MCP Tools Reference](mcp.md) for the tools exposed to agents. diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..a80f090 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,172 @@ +# MCP Tools Reference + +The workspace-vault MCP server exposes five tools to AI agents. These tools mirror standard file operations so agents can work with the vault naturally. + +## Setup + +Add to your MCP client config: + +```json +{ + "mcpServers": { + "workspace-vault": { + "command": "vault", + "args": ["mcp"] + } + } +} +``` + +The server communicates over **stdio** using the [Model Context Protocol](https://modelcontextprotocol.io/). + +--- + +## Tools + +### `vault_read_file` + +Read and decrypt a file from the vault. + +**Parameters:** +| Name | Type | Required | Description | +|------|------|:---:|-------------| +| `path` | string | Yes | Vault path of the file to read | + +**Returns:** Decrypted file content as text. + +**Requires unlock:** Yes + +**Example:** +```json +{ "tool": "vault_read_file", "arguments": { "path": "contracts/nda.pdf" } } +``` + +**When locked:** Returns an error with guidance: `"Vault is locked. Run 'vault unlock' in your terminal to unlock."` + +--- + +### `vault_create_file` + +Create a new encrypted file in the vault. + +**Parameters:** +| Name | Type | Required | Description | +|------|------|:---:|-------------| +| `path` | string | Yes | Vault path for the new file | +| `content` | string | Yes | File content to encrypt and store | +| `tags` | string[] | No | Tags for the file | + +**Returns:** Confirmation with path, size, and file ID. + +**Requires unlock:** Yes + +**Example:** +```json +{ + "tool": "vault_create_file", + "arguments": { + "path": "notes/meeting-2025-04-22.md", + "content": "# Meeting Notes\n\n- Discussed Q2 roadmap...", + "tags": ["meeting", "2025"] + } +} +``` + +--- + +### `vault_list_dir` + +List files in the vault with metadata. + +**Parameters:** +| Name | Type | Required | Description | +|------|------|:---:|-------------| +| `path` | string | No | Directory prefix to filter by | + +**Returns:** List of files with vault path, size, modification date, and tags. + +**Requires unlock:** No — works while locked. Shows metadata only, never content. + +**Example:** +```json +{ "tool": "vault_list_dir", "arguments": { "path": "contracts" } } +``` + +--- + +### `vault_grep_search` + +Search file contents using a pattern (regex supported). + +**Parameters:** +| Name | Type | Required | Description | +|------|------|:---:|-------------| +| `pattern` | string | Yes | Search pattern | + +**Returns:** Matching lines with file path and line number. + +**Requires unlock:** Yes — decrypts files to search their content. + +**Example:** +```json +{ "tool": "vault_grep_search", "arguments": { "pattern": "Acme Corp" } } +``` + +**Output format:** +``` +contracts/nda.pdf:12: This agreement between Acme Corp and... +contracts/nda.pdf:47: Acme Corp shall not disclose... +``` + +--- + +### `vault_file_search` + +Search for files by filename or tag. + +**Parameters:** +| Name | Type | Required | Description | +|------|------|:---:|-------------| +| `query` | string | Yes | Search query for filenames or tags | + +**Returns:** Matching files with match type (filename or tag) and matched value. + +**Requires unlock:** No — searches metadata only. + +**Example:** +```json +{ "tool": "vault_file_search", "arguments": { "query": "medical" } } +``` + +**Output format:** +``` +medical/lab-results.pdf (filename: lab-results.pdf) +finances/insurance.pdf (tag: medical) +``` + +--- + +## Locked vs. unlocked behavior + +| Tool | Locked | Unlocked | +|------|--------|----------| +| `vault_list_dir` | ✅ Returns metadata | ✅ Returns metadata | +| `vault_file_search` | ✅ Searches metadata | ✅ Searches metadata | +| `vault_read_file` | ❌ Error: vault locked | ✅ Returns content | +| `vault_create_file` | ❌ Error: vault locked | ✅ Creates file | +| `vault_grep_search` | ❌ Error: vault locked | ✅ Searches content | + +When a tool requires unlock and the vault is locked, it returns a clear error: + +``` +Error: Vault is locked. Run `vault unlock` in your terminal to unlock. +``` + +This is returned as an MCP error result (`isError: true`), so agents can handle it gracefully — for example, by asking the user to unlock. + +## Security notes + +- The MCP server **cannot** unlock the vault — only the CLI can +- The MCP server **cannot** manage keys, view audit logs, or initialize the vault +- All file content returned by the MCP server is sanitized (ANSI escape codes and control characters stripped) +- All operations are recorded in the audit log diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..0f1a68d --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,143 @@ +# Quickstart Guide + +Get your vault running in under 5 minutes. + +## 1. Install + +```bash +npm install -g workspace-vault +``` + +Verify it's installed: + +```bash +vault --version +``` + +## 2. Initialize your vault + +```bash +vault init +``` + +You'll be prompted to set a passphrase. This is your primary key — it encrypts the vault's master key. Choose something strong. + +By default the vault is created at `~/.vault`. To use a different location: + +```bash +vault init ~/my-vault +``` + +## 3. Store some files + +```bash +# From a local file +vault write contracts/nda.pdf --from ~/Documents/nda.pdf + +# With tags for easier searching +vault write medical/lab-results.pdf --from ~/Downloads/labs.pdf --tag medical --tag 2025 + +# From stdin +echo "API_KEY=sk-secret-12345" | vault write secrets/openai.txt +``` + +## 4. Verify your files are stored + +```bash +vault list +``` + +This works even when the vault is locked — it shows filenames, sizes, dates, and tags, but never file content. + +## 5. Read a file back + +The vault starts locked after init. Unlock it first: + +```bash +vault unlock +``` + +Then read: + +```bash +vault read contracts/nda.pdf +``` + +## 6. Connect your AI agent + +Add the MCP server to your agent's config. + +### VS Code / GitHub Copilot + +Create or edit `.vscode/mcp.json` in your workspace: + +```json +{ + "servers": { + "workspace-vault": { + "command": "vault", + "args": ["mcp"] + } + } +} +``` + +### Claude Desktop + +Edit `claude_desktop_config.json` (find it via Claude Desktop → Settings → Developer): + +```json +{ + "mcpServers": { + "workspace-vault": { + "command": "vault", + "args": ["mcp"] + } + } +} +``` + +### Cursor + +Add to your MCP config in Cursor settings: + +```json +{ + "mcpServers": { + "workspace-vault": { + "command": "vault", + "args": ["mcp"] + } + } +} +``` + +## 7. Use it + +1. Run `vault unlock` in your terminal +2. Ask your agent to read or search your vault files +3. The vault auto-locks after 30 minutes, or run `vault lock` manually + +### Example agent interactions + +> "Search my vault for the NDA with Acme Corp" +> +> "Read the lab results from last month" +> +> "Save this contract draft to my vault as contracts/acme-draft.md" + +## 8. Add a backup key + +Don't rely on a single passphrase. Add a backup: + +```bash +vault key add +``` + +You'll be prompted for a new passphrase and a label (e.g., "backup", "recovery"). Either key can unlock the vault independently. + +## Next steps + +- **[CLI Reference](cli.md)** — all commands and options +- **[MCP Tools Reference](mcp.md)** — what tools your agent sees +- **[Security Model](../spec/security.md)** — how encryption and access control work diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 2fc5b74..cebbc60 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -94,8 +94,8 @@ export async function initCommand(vaultPath?: string): Promise { `{\n` + ` "mcpServers": {\n` + ` "workspace-vault": {\n` + - ` "command": "npx",\n` + - ` "args": ["workspace-vault", "mcp"]\n` + + ` "command": "vault",\n` + + ` "args": ["mcp"]\n` + ` }\n` + ` }\n` + `}\n`, From 390bec32ec64cce74c7247d58f6d941b9e65e54f Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 20:05:09 -0700 Subject: [PATCH 15/23] style: format all files with Prettier Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/audit/logger.ts | 12 ++-- src/cli/commands.ts | 63 +++++------------- src/cli/index.ts | 12 +--- src/cli/prompt.ts | 4 +- src/config/index.ts | 6 +- src/crypto/age.ts | 18 ++---- src/crypto/index.ts | 7 +- src/crypto/keys.ts | 26 ++------ src/mcp/server.ts | 42 +++--------- src/security/paths.ts | 17 ++--- src/security/permissions.ts | 5 +- src/security/sanitize.ts | 4 +- src/session/manager.ts | 7 +- src/types.ts | 6 +- src/vault/engine.ts | 39 ++++------- src/vault/metadata.ts | 18 ++---- test/integration/vault.integration.test.ts | 44 +++++++++---- test/unit/crypto.test.ts | 12 +--- test/unit/mcp.test.ts | 9 +-- test/unit/security.test.ts | 71 ++++++-------------- test/unit/session.test.ts | 6 +- test/unit/vault.test.ts | 75 ++++++---------------- 22 files changed, 165 insertions(+), 338 deletions(-) diff --git a/src/audit/logger.ts b/src/audit/logger.ts index 683476b..ef10721 100644 --- a/src/audit/logger.ts +++ b/src/audit/logger.ts @@ -49,11 +49,7 @@ export class AuditLogger { /** * Query audit events with optional filtering. */ - getEvents(params?: { - limit?: number; - operation?: OperationType; - since?: Date; - }): AuditEntry[] { + getEvents(params?: { limit?: number; operation?: OperationType; since?: Date }): AuditEntry[] { const conditions: string[] = []; const values: unknown[] = []; @@ -101,9 +97,9 @@ export class AuditLogger { * Get total count of audit events. */ getEventCount(): number { - const row = this.db - .prepare('SELECT COUNT(*) AS count FROM audit_log') - .get() as { count: number }; + const row = this.db.prepare('SELECT COUNT(*) AS count FROM audit_log').get() as { + count: number; + }; return row.count; } diff --git a/src/cli/commands.ts b/src/cli/commands.ts index cebbc60..733578a 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -5,11 +5,7 @@ import { VaultEngine } from '../vault/index.js'; import { SessionManager } from '../session/index.js'; import { ConfigManager } from '../config/index.js'; import { sanitizeOutput } from '../security/index.js'; -import { - type OperationType, - VaultLockedError, - SessionExpiredError, -} from '../types.js'; +import { type OperationType, VaultLockedError, SessionExpiredError } from '../types.js'; import { promptPassphrase, promptPassphraseConfirm } from './prompt.js'; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -28,9 +24,7 @@ function getSession(config: ConfigManager): SessionManager { function requireInit(config: ConfigManager): void { if (!config.isInitialized()) { - process.stderr.write( - 'Vault not initialized. Run `vault init` first.\n', - ); + process.stderr.write('Vault not initialized. Run `vault init` first.\n'); process.exit(1); } } @@ -40,15 +34,11 @@ function requireUnlock(session: SessionManager): string { return session.readSession(); } catch (err) { if (err instanceof VaultLockedError) { - process.stderr.write( - 'Vault is locked. Run `vault unlock` first.\n', - ); + process.stderr.write('Vault is locked. Run `vault unlock` first.\n'); process.exit(1); } if (err instanceof SessionExpiredError) { - process.stderr.write( - 'Session expired. Run `vault unlock` again.\n', - ); + process.stderr.write('Session expired. Run `vault unlock` again.\n'); process.exit(1); } throw err; @@ -56,7 +46,10 @@ function requireUnlock(session: SessionManager): string { } function formatDate(d: Date): string { - return d.toISOString().replace('T', ' ').replace(/\.\d+Z$/, 'Z'); + return d + .toISOString() + .replace('T', ' ') + .replace(/\.\d+Z$/, 'Z'); } function formatSize(bytes: number): string { @@ -76,9 +69,7 @@ async function readStdin(): Promise { // ── Commands ───────────────────────────────────────────────────────────────── export async function initCommand(vaultPath?: string): Promise { - const resolvedPath = path.resolve( - vaultPath ?? path.join(os.homedir(), '.vault'), - ); + const resolvedPath = path.resolve(vaultPath ?? path.join(os.homedir(), '.vault')); const passphrase = await promptPassphraseConfirm(); @@ -181,15 +172,8 @@ export async function writeCommand( content = await readStdin(); } - const meta = await engine.writeFile( - vaultPath, - content, - masterKey, - options.tag, - ); - process.stderr.write( - `Written ${vaultPath} (${formatSize(meta.size)})\n`, - ); + const meta = await engine.writeFile(vaultPath, content, masterKey, options.tag); + process.stderr.write(`Written ${vaultPath} (${formatSize(meta.size)})\n`); } finally { engine.close(); } @@ -285,9 +269,7 @@ export async function grepCommand(pattern: string): Promise { return; } for (const r of results) { - process.stdout.write( - `${r.vaultPath}:${r.lineNumber}: ${sanitizeOutput(r.line)}\n`, - ); + process.stdout.write(`${r.vaultPath}:${r.lineNumber}: ${sanitizeOutput(r.line)}\n`); } } finally { engine.close(); @@ -340,13 +322,9 @@ export function keyListCommand(): void { return; } - process.stdout.write( - `${'ID'.padEnd(38)} ${'LABEL'.padEnd(24)} CREATED\n`, - ); + process.stdout.write(`${'ID'.padEnd(38)} ${'LABEL'.padEnd(24)} CREATED\n`); for (const k of keys) { - process.stdout.write( - `${k.id.padEnd(38)} ${k.label.padEnd(24)} ${formatDate(k.createdAt)}\n`, - ); + process.stdout.write(`${k.id.padEnd(38)} ${k.label.padEnd(24)} ${formatDate(k.createdAt)}\n`); } } finally { engine.close(); @@ -368,10 +346,7 @@ export function keyRevokeCommand(keyId: string): void { } } -export function auditCommand(options: { - tail?: string; - operation?: string; -}): void { +export function auditCommand(options: { tail?: string; operation?: string }): void { const config = getConfig(); requireInit(config); const engine = getEngine(config); @@ -379,9 +354,7 @@ export function auditCommand(options: { try { const limit = options.tail ? parseInt(options.tail, 10) : undefined; const operation = options.operation as OperationType | undefined; - const events = engine - .getAuditLogger() - .getEvents({ limit, operation }); + const events = engine.getAuditLogger().getEvents({ limit, operation }); if (events.length === 0) { process.stderr.write('No audit events.\n'); @@ -408,9 +381,7 @@ export async function mcpCommand(): Promise { const session = getSession(config); const { createVaultMcpServer } = await import('../mcp/index.js'); - const { StdioServerTransport } = await import( - '@modelcontextprotocol/sdk/server/stdio.js' - ); + const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js'); const server = createVaultMcpServer(engine, session); const transport = new StdioServerTransport(); diff --git a/src/cli/index.ts b/src/cli/index.ts index bb19d2f..1729077 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -112,9 +112,7 @@ program }); // Key management subcommand -const key = program - .command('key') - .description('Manage vault keys'); +const key = program.command('key').description('Manage vault keys'); key .command('add') @@ -164,9 +162,7 @@ async function main() { process.exit(1); } if (err instanceof VaultNotInitializedError) { - process.stderr.write( - 'Vault not initialized. Run `vault init` first.\n', - ); + process.stderr.write('Vault not initialized. Run `vault init` first.\n'); process.exit(1); } if (err instanceof InvalidKeyError) { @@ -177,9 +173,7 @@ async function main() { process.stderr.write(`Error: ${err.message}\n`); process.exit(1); } - process.stderr.write( - `Unexpected error: ${err instanceof Error ? err.message : String(err)}\n`, - ); + process.stderr.write(`Unexpected error: ${err instanceof Error ? err.message : String(err)}\n`); process.exit(2); } } diff --git a/src/cli/prompt.ts b/src/cli/prompt.ts index 6c09824..fd81d63 100644 --- a/src/cli/prompt.ts +++ b/src/cli/prompt.ts @@ -4,9 +4,7 @@ import { createInterface } from 'node:readline'; * Prompt for a passphrase without echoing input. * Writes prompt to stderr so stdout remains clean for piping. */ -export async function promptPassphrase( - prompt = 'Passphrase: ', -): Promise { +export async function promptPassphrase(prompt = 'Passphrase: '): Promise { return new Promise((resolve, reject) => { if (!process.stdin.isTTY) { reject(new Error('Cannot prompt for passphrase: stdin is not a TTY')); diff --git a/src/config/index.ts b/src/config/index.ts index 9855154..e4c0c4f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -11,7 +11,10 @@ const SESSION_FILE_NAME = 'session'; function getConfigDir(): string { if (process.platform === 'win32') { - return path.join(process.env['APPDATA'] || path.join(os.homedir(), 'AppData', 'Roaming'), CONFIG_DIR_NAME); + return path.join( + process.env['APPDATA'] || path.join(os.homedir(), 'AppData', 'Roaming'), + CONFIG_DIR_NAME, + ); } if (process.platform === 'darwin') { return path.join(os.homedir(), 'Library', 'Application Support', CONFIG_DIR_NAME); @@ -88,4 +91,3 @@ export class ConfigManager { return config; } } - diff --git a/src/crypto/age.ts b/src/crypto/age.ts index 7bbca55..d741119 100644 --- a/src/crypto/age.ts +++ b/src/crypto/age.ts @@ -4,10 +4,7 @@ import { InvalidKeyError } from '../types.js'; /** * Encrypt a buffer for one or more age recipients (public keys). */ -export async function encrypt( - plaintext: Buffer, - recipients: string[], -): Promise { +export async function encrypt(plaintext: Buffer, recipients: string[]): Promise { const e = new Encrypter(); for (const r of recipients) { e.addRecipient(r); @@ -19,19 +16,14 @@ export async function encrypt( /** * Decrypt a buffer using an age identity (private key). */ -export async function decrypt( - ciphertext: Buffer, - identity: string, -): Promise { +export async function decrypt(ciphertext: Buffer, identity: string): Promise { try { const d = new Decrypter(); d.addIdentity(identity); const plaintext = await d.decrypt(ciphertext); return Buffer.from(plaintext); } catch (err) { - throw new InvalidKeyError( - err instanceof Error ? err.message : 'Decryption failed', - ); + throw new InvalidKeyError(err instanceof Error ? err.message : 'Decryption failed'); } } @@ -62,8 +54,6 @@ export async function decryptWithPassphrase( const plaintext = await d.decrypt(ciphertext); return Buffer.from(plaintext); } catch (err) { - throw new InvalidKeyError( - err instanceof Error ? err.message : 'Decryption failed', - ); + throw new InvalidKeyError(err instanceof Error ? err.message : 'Decryption failed'); } } diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 8872fd4..3452d58 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -1,9 +1,4 @@ -export { - encrypt, - decrypt, - encryptWithPassphrase, - decryptWithPassphrase, -} from './age.js'; +export { encrypt, decrypt, encryptWithPassphrase, decryptWithPassphrase } from './age.js'; export { generateKeyPair, passphraseToIdentity, diff --git a/src/crypto/keys.ts b/src/crypto/keys.ts index 6f064ef..4c2e941 100644 --- a/src/crypto/keys.ts +++ b/src/crypto/keys.ts @@ -1,15 +1,7 @@ import { randomBytes } from 'node:crypto'; -import { - generateIdentity, - identityToRecipient, -} from 'age-encryption'; +import { generateIdentity, identityToRecipient } from 'age-encryption'; import { InvalidKeyError } from '../types.js'; -import { - encrypt, - decrypt, - encryptWithPassphrase, - decryptWithPassphrase, -} from './age.js'; +import { encrypt, decrypt, encryptWithPassphrase, decryptWithPassphrase } from './age.js'; /** * Generate a new age keypair for use as a master key. @@ -73,10 +65,7 @@ export async function wrapMasterKey( * If the identity starts with "passphrase:", it was created via * passphraseToIdentity and we use passphrase-based decryption. */ -export async function unwrapMasterKey( - wrappedKey: Buffer, - identity: string, -): Promise { +export async function unwrapMasterKey(wrappedKey: Buffer, identity: string): Promise { try { // If this is a passphrase-derived identity (from passphraseToIdentity), // use passphrase-based decryption. @@ -89,9 +78,7 @@ export async function unwrapMasterKey( return plaintext.toString('utf-8'); } catch (err) { if (err instanceof InvalidKeyError) throw err; - throw new InvalidKeyError( - err instanceof Error ? err.message : 'Failed to unwrap master key', - ); + throw new InvalidKeyError(err instanceof Error ? err.message : 'Failed to unwrap master key'); } } @@ -102,8 +89,5 @@ export async function wrapMasterKeyWithPassphrase( masterPrivateKey: string, passphraseIdentity: string, ): Promise { - return encryptWithPassphrase( - Buffer.from(masterPrivateKey, 'utf-8'), - passphraseIdentity, - ); + return encryptWithPassphrase(Buffer.from(masterPrivateKey, 'utf-8'), passphraseIdentity); } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index e6dec1d..41764ce 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -3,15 +3,10 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { VaultEngine } from '../vault/index.js'; import { SessionManager } from '../session/index.js'; import { sanitizeOutput } from '../security/index.js'; -import { - VaultLockedError, - SessionExpiredError, - type FileMetadata, -} from '../types.js'; +import { VaultLockedError, SessionExpiredError, type FileMetadata } from '../types.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -const LOCKED_MESSAGE = - 'Vault is locked. Run `vault unlock` in your terminal to unlock.'; +const LOCKED_MESSAGE = 'Vault is locked. Run `vault unlock` in your terminal to unlock.'; function getMasterKey(session: SessionManager): string { try { @@ -42,10 +37,7 @@ function formatMetadata(files: FileMetadata[]): string { .join('\n'); } -export function createVaultMcpServer( - engine: VaultEngine, - session: SessionManager, -): McpServer { +export function createVaultMcpServer(engine: VaultEngine, session: SessionManager): McpServer { const server = new McpServer( { name: 'workspace-vault', version: '0.1.0' }, { capabilities: { tools: {} } }, @@ -62,9 +54,7 @@ export function createVaultMcpServer( const masterKey = getMasterKey(session); const content = await engine.readFile(vaultPath, masterKey); return { - content: [ - { type: 'text', text: sanitizeOutput(content.toString('utf-8')) }, - ], + content: [{ type: 'text', text: sanitizeOutput(content.toString('utf-8')) }], }; } catch (err) { return errorResult((err as Error).message); @@ -80,10 +70,7 @@ export function createVaultMcpServer( { path: z.string().describe('Vault path for the new file'), content: z.string().describe('File content to encrypt and store'), - tags: z - .array(z.string()) - .optional() - .describe('Optional tags for the file'), + tags: z.array(z.string()).optional().describe('Optional tags for the file'), }, async ({ path: vaultPath, content, tags }): Promise => { try { @@ -116,18 +103,13 @@ export function createVaultMcpServer( 'vault_list_dir', 'List files in the vault. Shows metadata (names, sizes, dates, tags). Works even when the vault is locked.', { - path: z - .string() - .optional() - .describe('Optional directory prefix to filter by'), + path: z.string().optional().describe('Optional directory prefix to filter by'), }, ({ path: dirPath }): CallToolResult => { try { const files = engine.listFiles(dirPath); return { - content: [ - { type: 'text', text: sanitizeOutput(formatMetadata(files)) }, - ], + content: [{ type: 'text', text: sanitizeOutput(formatMetadata(files)) }], }; } catch (err) { return errorResult((err as Error).message); @@ -149,10 +131,7 @@ export function createVaultMcpServer( return { content: [{ type: 'text', text: 'No matches found.' }] }; } const text = results - .map( - (r) => - `${r.vaultPath}:${r.lineNumber}: ${sanitizeOutput(r.line)}`, - ) + .map((r) => `${r.vaultPath}:${r.lineNumber}: ${sanitizeOutput(r.line)}`) .join('\n'); return { content: [{ type: 'text', text }] }; } catch (err) { @@ -174,10 +153,7 @@ export function createVaultMcpServer( return { content: [{ type: 'text', text: 'No matches found.' }] }; } const text = results - .map( - (r) => - `${r.vaultPath} (${r.matchType}: ${sanitizeOutput(r.matchedValue)})`, - ) + .map((r) => `${r.vaultPath} (${r.matchType}: ${sanitizeOutput(r.matchedValue)})`) .join('\n'); return { content: [{ type: 'text', text }] }; } catch (err) { diff --git a/src/security/paths.ts b/src/security/paths.ts index dc6a811..4a8176c 100644 --- a/src/security/paths.ts +++ b/src/security/paths.ts @@ -13,10 +13,7 @@ const WINDOWS_RESERVED = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i; * @returns The resolved absolute path guaranteed to be within vaultRoot * @throws PathTraversalError if path escapes vault root */ -export function validateVaultPath( - userPath: string, - vaultRoot: string, -): string { +export function validateVaultPath(userPath: string, vaultRoot: string): string { // 1. Reject null bytes if (userPath.includes('\0')) { throw new PathTraversalError('Path contains null bytes'); @@ -37,9 +34,7 @@ export function validateVaultPath( : resolved === resolvedRoot || resolved.startsWith(rootWithSep); if (!isWithin) { - throw new PathTraversalError( - `Path "${userPath}" resolves outside vault root`, - ); + throw new PathTraversalError(`Path "${userPath}" resolves outside vault root`); } // 5. Windows-specific checks @@ -59,9 +54,7 @@ export function validateVaultPath( // Strip extension for reserved-name check (e.g. CON.txt is also reserved) if (WINDOWS_RESERVED.test(component)) { - throw new PathTraversalError( - `Path contains Windows reserved device name: "${component}"`, - ); + throw new PathTraversalError(`Path contains Windows reserved device name: "${component}"`); } } } @@ -70,9 +63,7 @@ export function validateVaultPath( try { const stat = fs.lstatSync(resolved); if (stat.isSymbolicLink()) { - throw new PathTraversalError( - `Path "${userPath}" is a symbolic link, which is not allowed`, - ); + throw new PathTraversalError(`Path "${userPath}" is a symbolic link, which is not allowed`); } } catch (err) { // If the file doesn't exist, that's fine (new file path) diff --git a/src/security/permissions.ts b/src/security/permissions.ts index 5b2cb83..53a9f1d 100644 --- a/src/security/permissions.ts +++ b/src/security/permissions.ts @@ -5,10 +5,7 @@ import fs from 'node:fs'; * POSIX: 0o700 for dirs, 0o600 for files * Windows: best-effort (fs.chmod may not work the same way) */ -export function setRestrictivePermissions( - filePath: string, - type: 'file' | 'directory', -): void { +export function setRestrictivePermissions(filePath: string, type: 'file' | 'directory'): void { const mode = type === 'directory' ? 0o700 : 0o600; try { fs.chmodSync(filePath, mode); diff --git a/src/security/sanitize.ts b/src/security/sanitize.ts index fe836f0..a8c4017 100644 --- a/src/security/sanitize.ts +++ b/src/security/sanitize.ts @@ -37,9 +37,7 @@ export function sanitizeSearchPattern(pattern: string): string { } if (pattern.length > MAX_PATTERN_LENGTH) { - throw new Error( - `Search pattern exceeds maximum length of ${MAX_PATTERN_LENGTH}`, - ); + throw new Error(`Search pattern exceeds maximum length of ${MAX_PATTERN_LENGTH}`); } return pattern; diff --git a/src/session/manager.ts b/src/session/manager.ts index 3bd1228..42d2122 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -1,5 +1,10 @@ import fs from 'node:fs'; -import { type SessionData, VaultLockedError, SessionExpiredError, SessionFileError } from '../types.js'; +import { + type SessionData, + VaultLockedError, + SessionExpiredError, + SessionFileError, +} from '../types.js'; import { setRestrictivePermissions } from '../security/index.js'; const DEFAULT_TTL_MINUTES = 30; diff --git a/src/types.ts b/src/types.ts index 06b5fb4..f7f23b9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -224,7 +224,7 @@ export const AuditQuerySchema = z.object({ // ── Session file types ─────────────────────────────────────────────────────── export interface SessionData { - masterKey: string; // hex-encoded master key - expiresAt: string; // ISO 8601 timestamp - createdAt: string; // ISO 8601 timestamp + masterKey: string; // hex-encoded master key + expiresAt: string; // ISO 8601 timestamp + createdAt: string; // ISO 8601 timestamp } diff --git a/src/vault/engine.ts b/src/vault/engine.ts index 365f327..c84e233 100644 --- a/src/vault/engine.ts +++ b/src/vault/engine.ts @@ -42,10 +42,7 @@ export class VaultEngine { // ── Init ──────────────────────────────────────────────────────────── - static async init( - vaultPath: string, - passphrase: string, - ): Promise { + static async init(vaultPath: string, passphrase: string): Promise { // 1. Create vault directory structure fs.mkdirSync(vaultPath, { recursive: true }); fs.mkdirSync(path.join(vaultPath, 'files'), { recursive: true }); @@ -57,8 +54,10 @@ export class VaultEngine { // 3. Wrap master key for the initial passphrase const salt = generateSalt(); - const { publicKey: passphrasePublicKey, identity } = - await passphraseToIdentity(passphrase, salt); + const { publicKey: passphrasePublicKey, identity } = await passphraseToIdentity( + passphrase, + salt, + ); const wrappedKey = await wrapMasterKeyWithPassphrase(privateKey, identity); // 4. Create engine and store initial key @@ -143,8 +142,7 @@ export class VaultEngine { this.validatePath(vaultFilePath); const meta = this.store.getFile(vaultFilePath); - if (!meta) - throw new FileNotFoundError(`File not found: ${vaultFilePath}`); + if (!meta) throw new FileNotFoundError(`File not found: ${vaultFilePath}`); const blobPath = path.join(this.filesDir, `${meta.blobId}.age`); if (fs.existsSync(blobPath)) fs.unlinkSync(blobPath); @@ -177,10 +175,7 @@ export class VaultEngine { })); } - async grepFiles( - pattern: string, - masterKey: string, - ): Promise { + async grepFiles(pattern: string, masterKey: string): Promise { const allFiles = this.store.getAllFiles(); const results: GrepResult[] = []; const regex = new RegExp(pattern, 'gi'); @@ -211,17 +206,10 @@ export class VaultEngine { // ── Key management ────────────────────────────────────────────────── - async addKey( - passphrase: string, - label: string, - currentMasterKey: string, - ): Promise { + async addKey(passphrase: string, label: string, currentMasterKey: string): Promise { const salt = generateSalt(); const { publicKey, identity } = await passphraseToIdentity(passphrase, salt); - const wrappedKey = await wrapMasterKeyWithPassphrase( - currentMasterKey, - identity, - ); + const wrappedKey = await wrapMasterKeyWithPassphrase(currentMasterKey, identity); const record = { id: randomUUID(), @@ -270,10 +258,7 @@ export class VaultEngine { for (const key of keys) { try { const { identity } = await passphraseToIdentity(passphrase, key.salt); - const masterKey = await unwrapMasterKey( - key.wrappedMasterKey, - identity, - ); + const masterKey = await unwrapMasterKey(key.wrappedMasterKey, identity); this.audit.logEvent({ operation: OperationType.UNLOCK, keyId: key.id, @@ -300,9 +285,7 @@ export class VaultEngine { } getMasterPublicKey(): string { - return fs - .readFileSync(path.join(this.vaultPath, 'master.pub'), 'utf-8') - .trim(); + return fs.readFileSync(path.join(this.vaultPath, 'master.pub'), 'utf-8').trim(); } getAuditLogger(): AuditLogger { diff --git a/src/vault/metadata.ts b/src/vault/metadata.ts index d3e5602..35c672c 100644 --- a/src/vault/metadata.ts +++ b/src/vault/metadata.ts @@ -58,9 +58,9 @@ export class MetadataStore { } getFile(vaultPath: string): FileMetadata | null { - const row = this.db - .prepare('SELECT * FROM files WHERE vault_path = ?') - .get(vaultPath) as RawFileRow | undefined; + const row = this.db.prepare('SELECT * FROM files WHERE vault_path = ?').get(vaultPath) as + | RawFileRow + | undefined; return row ? toFileMetadata(row) : null; } @@ -72,9 +72,7 @@ export class MetadataStore { .all(prefix + '%') as RawFileRow[]; return rows.map(toFileMetadata); } - const rows = this.db - .prepare('SELECT * FROM files ORDER BY vault_path') - .all() as RawFileRow[]; + const rows = this.db.prepare('SELECT * FROM files ORDER BY vault_path').all() as RawFileRow[]; return rows.map(toFileMetadata); } @@ -133,16 +131,12 @@ export class MetadataStore { } getKey(id: string): KeyRecordFull | null { - const row = this.db.prepare('SELECT * FROM keys WHERE id = ?').get(id) as - | RawKeyRow - | undefined; + const row = this.db.prepare('SELECT * FROM keys WHERE id = ?').get(id) as RawKeyRow | undefined; return row ? toKeyRecord(row) : null; } getAllKeys(): KeyRecordFull[] { - const rows = this.db - .prepare('SELECT * FROM keys ORDER BY created_at') - .all() as RawKeyRow[]; + const rows = this.db.prepare('SELECT * FROM keys ORDER BY created_at').all() as RawKeyRow[]; return rows.map(toKeyRecord); } diff --git a/test/integration/vault.integration.test.ts b/test/integration/vault.integration.test.ts index 5d4ae99..81b920a 100644 --- a/test/integration/vault.integration.test.ts +++ b/test/integration/vault.integration.test.ts @@ -53,7 +53,11 @@ describe('Integration: Full vault lifecycle', { timeout: 60_000 }, () => { afterEach(() => { for (const e of engines) { - try { e.close(); } catch { /* already closed */ } + try { + e.close(); + } catch { + /* already closed */ + } } engines.length = 0; for (const d of dirs) cleanup(d); @@ -124,9 +128,24 @@ describe('Integration: Full vault lifecycle', { timeout: 60_000 }, () => { const masterKey = await engine.unlock(TEST_PASSPHRASE); // Write multiple files - await engine.writeFile('src/main.ts', Buffer.from('export function main() { return 42; }'), masterKey, ['typescript', 'entry']); - await engine.writeFile('src/utils.ts', Buffer.from('export function add(a: number, b: number) { return a + b; }'), masterKey, ['typescript', 'utils']); - await engine.writeFile('docs/readme.md', Buffer.from('# Project\nThis is the readme file.'), masterKey, ['documentation']); + await engine.writeFile( + 'src/main.ts', + Buffer.from('export function main() { return 42; }'), + masterKey, + ['typescript', 'entry'], + ); + await engine.writeFile( + 'src/utils.ts', + Buffer.from('export function add(a: number, b: number) { return a + b; }'), + masterKey, + ['typescript', 'utils'], + ); + await engine.writeFile( + 'docs/readme.md', + Buffer.from('# Project\nThis is the readme file.'), + masterKey, + ['documentation'], + ); await engine.writeFile('config.json', Buffer.from('{"port": 3000}'), masterKey, ['config']); // List all @@ -136,8 +155,8 @@ describe('Integration: Full vault lifecycle', { timeout: 60_000 }, () => { // List by prefix const srcFiles = engine.listFiles('src'); expect(srcFiles).toHaveLength(2); - expect(srcFiles.map(f => f.vaultPath)).toContain('src/main.ts'); - expect(srcFiles.map(f => f.vaultPath)).toContain('src/utils.ts'); + expect(srcFiles.map((f) => f.vaultPath)).toContain('src/main.ts'); + expect(srcFiles.map((f) => f.vaultPath)).toContain('src/utils.ts'); // Search by name const nameResults = engine.searchFiles('readme'); @@ -151,7 +170,7 @@ describe('Integration: Full vault lifecycle', { timeout: 60_000 }, () => { // Grep content const grepResults = await engine.grepFiles('function', masterKey); expect(grepResults.length).toBeGreaterThanOrEqual(2); - const grepPaths = grepResults.map(r => r.vaultPath); + const grepPaths = grepResults.map((r) => r.vaultPath); expect(grepPaths).toContain('src/main.ts'); expect(grepPaths).toContain('src/utils.ts'); @@ -252,7 +271,10 @@ describe('Integration: Full vault lifecycle', { timeout: 60_000 }, () => { expect(readLocked.isError).toBe(true); expect(readLocked.content[0].text).toContain('vault unlock'); - const createLocked = await callTool(server, 'vault_create_file', { path: 'x.txt', content: 'hi' }); + const createLocked = await callTool(server, 'vault_create_file', { + path: 'x.txt', + content: 'hi', + }); expect(createLocked.isError).toBe(true); const grepLocked = await callTool(server, 'vault_grep_search', { pattern: 'test' }); @@ -417,9 +439,9 @@ describe('Integration: Full vault lifecycle', { timeout: 60_000 }, () => { const masterKey = await engine.unlock(TEST_PASSPHRASE); await engine.writeFile('dup.txt', Buffer.from('first'), masterKey); - await expect( - engine.writeFile('dup.txt', Buffer.from('second'), masterKey), - ).rejects.toThrow(FileAlreadyExistsError); + await expect(engine.writeFile('dup.txt', Buffer.from('second'), masterKey)).rejects.toThrow( + FileAlreadyExistsError, + ); // Original content preserved const content = await engine.readFile('dup.txt', masterKey); diff --git a/test/unit/crypto.test.ts b/test/unit/crypto.test.ts index 0c0a7b8..6315928 100644 --- a/test/unit/crypto.test.ts +++ b/test/unit/crypto.test.ts @@ -72,9 +72,7 @@ describe('crypto/age passphrase', () => { it('decryption with wrong passphrase throws InvalidKeyError', async () => { const plaintext = Buffer.from('secret'); const ciphertext = await encryptWithPassphrase(plaintext, 'correct-pass'); - await expect( - decryptWithPassphrase(ciphertext, 'wrong-pass'), - ).rejects.toThrow(InvalidKeyError); + await expect(decryptWithPassphrase(ciphertext, 'wrong-pass')).rejects.toThrow(InvalidKeyError); }); }); @@ -133,9 +131,7 @@ describe('crypto/keys', () => { const wrongKp = await generateKeyPair(); const wrapped = await wrapMasterKey(masterKp.privateKey, recipientKp.publicKey); - await expect(unwrapMasterKey(wrapped, wrongKp.privateKey)).rejects.toThrow( - InvalidKeyError, - ); + await expect(unwrapMasterKey(wrapped, wrongKp.privateKey)).rejects.toThrow(InvalidKeyError); }); it('wrapMasterKeyWithPassphrase + unwrapMasterKey round-trip works', async () => { @@ -155,8 +151,6 @@ describe('crypto/keys', () => { const { identity: wrongIdentity } = await passphraseToIdentity('wrong-passphrase', salt); const wrapped = await wrapMasterKeyWithPassphrase(masterKp.privateKey, identity); - await expect(unwrapMasterKey(wrapped, wrongIdentity)).rejects.toThrow( - InvalidKeyError, - ); + await expect(unwrapMasterKey(wrapped, wrongIdentity)).rejects.toThrow(InvalidKeyError); }); }); diff --git a/test/unit/mcp.test.ts b/test/unit/mcp.test.ts index 9d20c46..dfd5766 100644 --- a/test/unit/mcp.test.ts +++ b/test/unit/mcp.test.ts @@ -73,12 +73,9 @@ describe('MCP Server', () => { masterKey, ['doc', 'readme'], ); - await engine.writeFile( - 'secrets/api-key.txt', - Buffer.from('sk-secret-key-12345'), - masterKey, - ['secret'], - ); + await engine.writeFile('secrets/api-key.txt', Buffer.from('sk-secret-key-12345'), masterKey, [ + 'secret', + ]); }); afterEach(() => { diff --git a/test/unit/security.test.ts b/test/unit/security.test.ts index 122cb59..3e51450 100644 --- a/test/unit/security.test.ts +++ b/test/unit/security.test.ts @@ -11,14 +11,8 @@ import { import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { - validateVaultPath, - hasAlternateDataStream, -} from '../../src/security/paths.js'; -import { - sanitizeOutput, - sanitizeSearchPattern, -} from '../../src/security/sanitize.js'; +import { validateVaultPath, hasAlternateDataStream } from '../../src/security/paths.js'; +import { sanitizeOutput, sanitizeSearchPattern } from '../../src/security/sanitize.js'; import { setRestrictivePermissions } from '../../src/security/permissions.js'; import { PathTraversalError } from '../../src/types.js'; @@ -46,29 +40,22 @@ describe('security/paths', () => { }); it('rejects path with ../ traversal', () => { - expect(() => validateVaultPath('../outside.txt', vaultRoot)).toThrow( - PathTraversalError, - ); + expect(() => validateVaultPath('../outside.txt', vaultRoot)).toThrow(PathTraversalError); }); it('rejects deeply nested ../ traversal', () => { - expect(() => - validateVaultPath('a/b/../../../../etc/passwd', vaultRoot), - ).toThrow(PathTraversalError); + expect(() => validateVaultPath('a/b/../../../../etc/passwd', vaultRoot)).toThrow( + PathTraversalError, + ); }); it('rejects absolute path outside vault', () => { - const outsidePath = - process.platform === 'win32' ? 'C:\\Windows\\System32' : '/etc/passwd'; - expect(() => validateVaultPath(outsidePath, vaultRoot)).toThrow( - PathTraversalError, - ); + const outsidePath = process.platform === 'win32' ? 'C:\\Windows\\System32' : '/etc/passwd'; + expect(() => validateVaultPath(outsidePath, vaultRoot)).toThrow(PathTraversalError); }); it('rejects null bytes in path', () => { - expect(() => validateVaultPath('file\0.txt', vaultRoot)).toThrow( - PathTraversalError, - ); + expect(() => validateVaultPath('file\0.txt', vaultRoot)).toThrow(PathTraversalError); }); it('rejects symlinks pointing outside vault', () => { @@ -79,9 +66,7 @@ describe('security/paths', () => { // Symlink creation may require elevated privileges on Windows return; } - expect(() => validateVaultPath('evil-link', vaultRoot)).toThrow( - PathTraversalError, - ); + expect(() => validateVaultPath('evil-link', vaultRoot)).toThrow(PathTraversalError); }); it('handles path with trailing slashes', () => { @@ -116,31 +101,21 @@ describe('security/paths', () => { // Windows-specific tests const isWindows = process.platform === 'win32'; - it.skipIf(!isWindows)( - 'rejects Windows ADS (alternate data streams)', - () => { - expect(() => validateVaultPath('file.txt:Zone.Identifier', vaultRoot)).toThrow( - PathTraversalError, - ); - }, - ); + it.skipIf(!isWindows)('rejects Windows ADS (alternate data streams)', () => { + expect(() => validateVaultPath('file.txt:Zone.Identifier', vaultRoot)).toThrow( + PathTraversalError, + ); + }); it.skipIf(!isWindows)('rejects Windows reserved device names', () => { for (const name of ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'LPT1']) { - expect(() => validateVaultPath(name, vaultRoot)).toThrow( - PathTraversalError, - ); + expect(() => validateVaultPath(name, vaultRoot)).toThrow(PathTraversalError); } }); - it.skipIf(!isWindows)( - 'rejects Windows reserved names with extension', - () => { - expect(() => validateVaultPath('CON.txt', vaultRoot)).toThrow( - PathTraversalError, - ); - }, - ); + it.skipIf(!isWindows)('rejects Windows reserved names with extension', () => { + expect(() => validateVaultPath('CON.txt', vaultRoot)).toThrow(PathTraversalError); + }); }); describe('hasAlternateDataStream', () => { @@ -212,9 +187,7 @@ describe('sanitizeSearchPattern', () => { }); it('rejects extremely long patterns', () => { - expect(() => sanitizeSearchPattern('a'.repeat(2000))).toThrow( - /maximum length/, - ); + expect(() => sanitizeSearchPattern('a'.repeat(2000))).toThrow(/maximum length/); }); it('accepts pattern at max length', () => { @@ -263,8 +236,6 @@ describe('security/permissions', () => { it('does not throw for directory on any platform', () => { const dirPath = join(tempDir, 'test-dir'); mkdirSync(dirPath); - expect(() => - setRestrictivePermissions(dirPath, 'directory'), - ).not.toThrow(); + expect(() => setRestrictivePermissions(dirPath, 'directory')).not.toThrow(); }); }); diff --git a/test/unit/session.test.ts b/test/unit/session.test.ts index 3af2650..36dc3e4 100644 --- a/test/unit/session.test.ts +++ b/test/unit/session.test.ts @@ -73,7 +73,11 @@ describe('SessionManager', () => { it('cleans up expired session file', () => { manager.writeSession('deadbeef', 0); - try { manager.readSession(); } catch { /* expected */ } + try { + manager.readSession(); + } catch { + /* expected */ + } expect(existsSync(sessionPath)).toBe(false); }); }); diff --git a/test/unit/vault.test.ts b/test/unit/vault.test.ts index b0e0a9c..ef77d52 100644 --- a/test/unit/vault.test.ts +++ b/test/unit/vault.test.ts @@ -227,10 +227,7 @@ describe('VaultEngine', () => { }); it('stores a master public key file', () => { - const pubKey = fs.readFileSync( - path.join(vaultDir, 'master.pub'), - 'utf-8', - ); + const pubKey = fs.readFileSync(path.join(vaultDir, 'master.pub'), 'utf-8'); expect(pubKey.trim()).toMatch(/^age1/); }); @@ -253,9 +250,7 @@ describe('VaultEngine', () => { it('writeFile creates metadata with correct fields', async () => { const content = Buffer.from('data'); - const meta = await engine.writeFile('test.bin', content, masterKey, [ - 'binary', - ]); + const meta = await engine.writeFile('test.bin', content, masterKey, ['binary']); expect(meta.vaultPath).toBe('test.bin'); expect(meta.size).toBe(4); expect(meta.tags).toEqual(['binary']); @@ -275,15 +270,15 @@ describe('VaultEngine', () => { it('writeFile on existing path throws FileAlreadyExistsError', async () => { await engine.writeFile('dup.txt', Buffer.from('first'), masterKey); - await expect( - engine.writeFile('dup.txt', Buffer.from('second'), masterKey), - ).rejects.toThrow(FileAlreadyExistsError); + await expect(engine.writeFile('dup.txt', Buffer.from('second'), masterKey)).rejects.toThrow( + FileAlreadyExistsError, + ); }); it('readFile on non-existent file throws FileNotFoundError', async () => { - await expect( - engine.readFile('nonexistent.txt', masterKey), - ).rejects.toThrow(FileNotFoundError); + await expect(engine.readFile('nonexistent.txt', masterKey)).rejects.toThrow( + FileNotFoundError, + ); }); }); @@ -291,25 +286,17 @@ describe('VaultEngine', () => { describe('deleteFile', () => { it('removes blob and metadata', async () => { - const meta = await engine.writeFile( - 'delete-me.txt', - Buffer.from('bye'), - masterKey, - ); + const meta = await engine.writeFile('delete-me.txt', Buffer.from('bye'), masterKey); const blobPath = path.join(vaultDir, 'files', `${meta.blobId}.age`); expect(fs.existsSync(blobPath)).toBe(true); await engine.deleteFile('delete-me.txt'); expect(fs.existsSync(blobPath)).toBe(false); - await expect( - engine.readFile('delete-me.txt', masterKey), - ).rejects.toThrow(FileNotFoundError); + await expect(engine.readFile('delete-me.txt', masterKey)).rejects.toThrow(FileNotFoundError); }); it('on non-existent file throws FileNotFoundError', async () => { - await expect(engine.deleteFile('ghost.txt')).rejects.toThrow( - FileNotFoundError, - ); + await expect(engine.deleteFile('ghost.txt')).rejects.toThrow(FileNotFoundError); }); }); @@ -337,11 +324,7 @@ describe('VaultEngine', () => { describe('searchFiles', () => { it('matches filenames', async () => { - await engine.writeFile( - 'contracts/lease.pdf', - Buffer.from('pdf'), - masterKey, - ); + await engine.writeFile('contracts/lease.pdf', Buffer.from('pdf'), masterKey); await engine.writeFile('notes/todo.md', Buffer.from('md'), masterKey); const results = engine.searchFiles('lease'); expect(results).toHaveLength(1); @@ -354,16 +337,8 @@ describe('VaultEngine', () => { describe('grepFiles', () => { it('finds content matches across files', async () => { - await engine.writeFile( - 'file1.txt', - Buffer.from('line1\nfind me here\nline3'), - masterKey, - ); - await engine.writeFile( - 'file2.txt', - Buffer.from('nothing interesting'), - masterKey, - ); + await engine.writeFile('file1.txt', Buffer.from('line1\nfind me here\nline3'), masterKey); + await engine.writeFile('file2.txt', Buffer.from('nothing interesting'), masterKey); const results = await engine.grepFiles('find me', masterKey); expect(results).toHaveLength(1); expect(results[0].vaultPath).toBe('file1.txt'); @@ -376,11 +351,7 @@ describe('VaultEngine', () => { describe('addKey', () => { it('creates a new passphrase key', async () => { - const record = await engine.addKey( - 'second-passphrase-1234', - 'backup key', - masterKey, - ); + const record = await engine.addKey('second-passphrase-1234', 'backup key', masterKey); expect(record.label).toBe('backup key'); expect(record.id).toBeDefined(); expect(engine.listKeys()).toHaveLength(2); @@ -401,19 +372,13 @@ describe('VaultEngine', () => { }, 30_000); it('with wrong passphrase throws InvalidKeyError', async () => { - await expect(engine.unlock('wrong-passphrase-9999')).rejects.toThrow( - InvalidKeyError, - ); + await expect(engine.unlock('wrong-passphrase-9999')).rejects.toThrow(InvalidKeyError); }); }); describe('revokeKey', () => { it('removes key', async () => { - const second = await engine.addKey( - 'second-passphrase-1234', - 'second', - masterKey, - ); + const second = await engine.addKey('second-passphrase-1234', 'second', masterKey); expect(engine.listKeys()).toHaveLength(2); engine.revokeKey(second.id); expect(engine.listKeys()).toHaveLength(1); @@ -426,9 +391,9 @@ describe('VaultEngine', () => { }); it('on non-existent key throws KeyNotFoundError', () => { - expect(() => - engine.revokeKey('00000000-0000-0000-0000-000000000000'), - ).toThrow(KeyNotFoundError); + expect(() => engine.revokeKey('00000000-0000-0000-0000-000000000000')).toThrow( + KeyNotFoundError, + ); }); }); From 9b953934107b0606dfab2e7bf5f9c06c67711a8a Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 20:21:48 -0700 Subject: [PATCH 16/23] docs: add CHANGELOG for v0.1.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2ffaac1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] — 2026-04-22 + +Initial release. A working encrypted file vault with CLI and MCP server. + +### Added + +- **CLI** with full command suite: `init`, `unlock`, `lock`, `status`, `write`, `read`, `delete`, `list`, `search`, `grep`, `key add/list/revoke`, `audit`, `mcp` +- **MCP server** exposing 5 tools to AI agents: `vault_read_file`, `vault_create_file`, `vault_list_dir`, `vault_grep_search`, `vault_file_search` +- **age encryption** for all file content — no custom cryptography +- **Multi-key access** — add backup passphrases, revoke compromised keys without re-encrypting +- **Session-based unlock** — passphrase unlocks the vault for 30 minutes; MCP server reads session state on demand +- **SQLite metadata store** — file metadata (names, sizes, dates, tags) queryable while locked +- **Audit logging** — every operation recorded with timestamp, type, and target path (never content) +- **Security hardening** — path traversal prevention, output sanitization, restrictive file permissions +- **Cross-platform support** — macOS, Linux, Windows +- **Documentation** — README, quickstart guide, CLI reference, MCP tools reference, security model +- **CI** — GitHub Actions workflow running build, lint, and tests on all 3 platforms +- **154 tests** — unit tests for every module plus end-to-end integration tests From b5f9ebe5bb7b76453937e20bae25f00082d822e0 Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 20:34:58 -0700 Subject: [PATCH 17/23] docs: add experimental disclaimer to README and LICENSE Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- LICENSE | 5 +++++ README.md | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index a7cda9a..16148b8 100644 --- a/LICENSE +++ b/LICENSE @@ -2,6 +2,11 @@ MIT License Copyright (c) 2026 ThingsAI.io +EXPERIMENTAL SOFTWARE — This project is in early development. It is provided +as-is with no guarantees of stability, backward compatibility, or fitness for +any particular purpose. Use at your own risk. Do not use as your sole storage +for important data. + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights diff --git a/README.md b/README.md index 869a1d0..0f5dfe1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Workspace Vault +> **⚠️ Experimental** — This project is in early development and provided as-is. APIs, file formats, and encryption schemes may change between versions. Do not rely on this for production use or as your only copy of important files. Always keep backups of your original data. + An encrypted file vault with CLI and MCP server that gives AI agents controlled access to your private files. Files are encrypted at rest using [age](https://age-encryption.org/). You control when the vault is unlocked. Your agent works with files through standard operations — read, write, search, list — and when the session ends, everything locks again. @@ -120,4 +122,6 @@ See [spec/security.md](spec/security.md) for the full security model. ## License -MIT +MIT — see [LICENSE](LICENSE). + +This software is experimental and provided "as is", without warranty of any kind. Use at your own risk. From 9149bafd7c4e63a96fa571ca6b844d892d2ac668 Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 20:37:50 -0700 Subject: [PATCH 18/23] docs: fix Node.js version requirement to 20+ Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0f5dfe1..81154cd 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ See [spec/security.md](spec/security.md) for the full security model. ## Requirements -- Node.js 18+ +- Node.js 20+ - macOS, Linux, or Windows ## License From 1e598c73fca175389ec76f977351336709786bf7 Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 20:39:40 -0700 Subject: [PATCH 19/23] chore: bump minimum Node.js to 22 (current LTS) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- README.md | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76de105..5d01b31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - node-version: [20] + node-version: [22] steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 81154cd..175e963 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ See [spec/security.md](spec/security.md) for the full security model. ## Requirements -- Node.js 20+ +- Node.js 22+ - macOS, Linux, or Windows ## License diff --git a/package.json b/package.json index ef87ace..b7ba16c 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "prepublishOnly": "npm run build" }, "engines": { - "node": ">=20" + "node": ">=22" }, "keywords": [ "vault", From e4f0f87a95f3d4ef770d2ae3c8c35c86c6bd1be5 Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 20:44:28 -0700 Subject: [PATCH 20/23] fix: enforce LF line endings across platforms Add .gitattributes with eol=lf and set Prettier endOfLine to lf. Fixes Prettier check failing on Windows CI due to CRLF mismatch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitattributes | 2 ++ .prettierrc | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5e316bd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Ensure consistent line endings across platforms +* text=auto eol=lf diff --git a/.prettierrc b/.prettierrc index 4cbc711..ef2237c 100644 --- a/.prettierrc +++ b/.prettierrc @@ -3,5 +3,6 @@ "singleQuote": true, "trailingComma": "all", "printWidth": 100, - "tabWidth": 2 + "tabWidth": 2, + "endOfLine": "lf" } From 2ad2964a14f01a80e666b66c8e6612c82da204f3 Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 21:19:13 -0700 Subject: [PATCH 21/23] refactor: migrate from better-sqlite3 to sql.js (WASM) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes native addon dependency and deprecated prebuild-install. Pure WASM SQLite — no platform-specific compilation needed. - Replace better-sqlite3 with sql.js and @types/sql.js - Convert AuditLogger and MetadataStore to async factory pattern (static create()) - Add VaultEngine.open() static async factory for opening existing vaults - Persist database to disk after every write operation - Remove WAL mode pragmas (not applicable to sql.js) - Convert sync CLI commands to async where needed - Update all tests for async construction Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 452 +++------------------------------------- package.json | 4 +- src/audit/logger.ts | 90 ++++---- src/cli/commands.ts | 42 ++-- src/cli/index.ts | 24 +-- src/vault/engine.ts | 14 +- src/vault/metadata.ts | 159 +++++++++----- test/unit/audit.test.ts | 46 ++-- test/unit/vault.test.ts | 4 +- 9 files changed, 263 insertions(+), 572 deletions(-) diff --git a/package-lock.json b/package-lock.json index e63bdf6..c3a2c6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "age-encryption": "^0.3.0", - "better-sqlite3": "^12.9.0", "commander": "^14.0.3", + "sql.js": "^1.14.1", "zod": "^4.3.6" }, "bin": { @@ -20,8 +20,8 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.6.0", + "@types/sql.js": "^1.4.11", "eslint": "^10.2.1", "prettier": "^3.8.3", "typescript": "^6.0.3", @@ -29,7 +29,7 @@ "vitest": "^4.1.5" }, "engines": { - "node": ">=20" + "node": ">=22" } }, "node_modules/@emnapi/core": { @@ -739,16 +739,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/better-sqlite3": { - "version": "7.6.13", - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", - "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -767,6 +757,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -798,6 +795,17 @@ "undici-types": "~7.19.0" } }, + "node_modules/@types/sql.js": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.11.tgz", + "integrity": "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/emscripten": "*", + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", @@ -1243,60 +1251,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/better-sqlite3": { - "version": "12.9.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", - "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -1334,30 +1288,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1406,12 +1336,6 @@ "node": ">=18" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -1516,30 +1440,6 @@ } } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1560,6 +1460,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -1594,15 +1495,6 @@ "node": ">= 0.8" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1875,15 +1767,6 @@ "node": ">=18.0.0" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2022,12 +1905,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -2105,12 +1982,6 @@ "node": ">= 0.8" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2172,12 +2043,6 @@ "node": ">= 0.4" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2272,26 +2137,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2318,12 +2163,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -2790,18 +2629,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2818,21 +2645,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2858,12 +2670,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2880,18 +2686,6 @@ "node": ">= 0.6" } }, - "node_modules/node-abi": { - "version": "3.89.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", - "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3098,33 +2892,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3164,16 +2931,6 @@ "node": ">= 0.10" } }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3223,35 +2980,6 @@ "node": ">= 0.10" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3311,26 +3039,6 @@ "node": ">= 18" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3341,6 +3049,7 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3500,51 +3209,6 @@ "dev": true, "license": "ISC" }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3555,6 +3219,12 @@ "node": ">=0.10.0" } }, + "node_modules/sql.js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", + "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "license": "MIT" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3578,52 +3248,6 @@ "dev": true, "license": "MIT" }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3698,18 +3322,6 @@ "license": "0BSD", "optional": true }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3801,12 +3413,6 @@ "punycode": "^2.1.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index b7ba16c..78453be 100644 --- a/package.json +++ b/package.json @@ -43,14 +43,14 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "age-encryption": "^0.3.0", - "better-sqlite3": "^12.9.0", "commander": "^14.0.3", + "sql.js": "^1.14.1", "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.6.0", + "@types/sql.js": "^1.4.11", "eslint": "^10.2.1", "prettier": "^3.8.3", "typescript": "^6.0.3", diff --git a/src/audit/logger.ts b/src/audit/logger.ts index ef10721..3f395b1 100644 --- a/src/audit/logger.ts +++ b/src/audit/logger.ts @@ -1,16 +1,22 @@ -import Database from 'better-sqlite3'; +import fs from 'node:fs'; +import initSqlJs, { type Database as SqlJsDatabase } from 'sql.js'; import { OperationType, type AuditEntry } from '../types.js'; export class AuditLogger { - private db: Database.Database; - private insertStmt: Database.Statement; + private db: SqlJsDatabase; + private dbPath: string; - constructor(dbPath: string) { - this.db = new Database(dbPath); + private constructor(db: SqlJsDatabase, dbPath: string) { + this.db = db; + this.dbPath = dbPath; + } - this.db.pragma('journal_mode = WAL'); + static async create(dbPath: string): Promise { + const SQL = await initSqlJs(); + const fileBuffer = fs.existsSync(dbPath) ? fs.readFileSync(dbPath) : undefined; + const db = new SQL.Database(fileBuffer); - this.db.exec(` + db.run(` CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp TEXT NOT NULL, @@ -21,9 +27,14 @@ export class AuditLogger { ) `); - this.insertStmt = this.db.prepare( - 'INSERT INTO audit_log (timestamp, operation, target_path, key_id, success) VALUES (?, ?, ?, ?, ?)', - ); + const logger = new AuditLogger(db, dbPath); + logger.persist(); + return logger; + } + + private persist(): void { + const data = this.db.export(); + fs.writeFileSync(this.dbPath, Buffer.from(data)); } /** @@ -37,13 +48,11 @@ export class AuditLogger { }): void { const timestamp = new Date().toISOString(); const success = params.success === undefined ? 1 : params.success ? 1 : 0; - this.insertStmt.run( - timestamp, - params.operation, - params.targetPath ?? null, - params.keyId ?? null, - success, + this.db.run( + 'INSERT INTO audit_log (timestamp, operation, target_path, key_id, success) VALUES (?, ?, ?, ?, ?)', + [timestamp, params.operation, params.targetPath ?? null, params.keyId ?? null, success], ); + this.persist(); } /** @@ -74,32 +83,39 @@ export class AuditLogger { values.push(params.limit); } - const rows = this.db.prepare(sql).all(...values) as Array<{ - id: number; - timestamp: string; - operation: string; - target_path: string | null; - key_id: string | null; - success: number; - }>; - - return rows.map((row) => ({ - id: row.id, - timestamp: new Date(row.timestamp), - operation: row.operation as OperationType, - targetPath: row.target_path ?? undefined, - keyId: row.key_id ?? undefined, - success: row.success === 1, - })); + const results: AuditEntry[] = []; + const stmt = this.db.prepare(sql); + stmt.bind(values as SqlJsBindParams); + while (stmt.step()) { + const row = stmt.getAsObject() as { + id: number; + timestamp: string; + operation: string; + target_path: string | null; + key_id: string | null; + success: number; + }; + results.push({ + id: row.id, + timestamp: new Date(row.timestamp), + operation: row.operation as OperationType, + targetPath: row.target_path ?? undefined, + keyId: row.key_id ?? undefined, + success: row.success === 1, + }); + } + stmt.free(); + return results; } /** * Get total count of audit events. */ getEventCount(): number { - const row = this.db.prepare('SELECT COUNT(*) AS count FROM audit_log').get() as { - count: number; - }; + const stmt = this.db.prepare('SELECT COUNT(*) AS count FROM audit_log'); + stmt.step(); + const row = stmt.getAsObject() as { count: number }; + stmt.free(); return row.count; } @@ -110,3 +126,5 @@ export class AuditLogger { this.db.close(); } } + +type SqlJsBindParams = Parameters[1]; diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 733578a..7c73fad 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -14,8 +14,8 @@ function getConfig(): ConfigManager { return new ConfigManager(); } -function getEngine(config: ConfigManager): VaultEngine { - return new VaultEngine(config.getVaultPath()); +async function getEngine(config: ConfigManager): Promise { + return VaultEngine.open(config.getVaultPath()); } function getSession(config: ConfigManager): SessionManager { @@ -104,7 +104,7 @@ export async function unlockCommand(): Promise { } const passphrase = await promptPassphrase(); - const engine = getEngine(config); + const engine = await getEngine(config); try { const masterKey = await engine.unlock(passphrase); session.writeSession(masterKey); @@ -123,7 +123,7 @@ export function lockCommand(): void { process.stderr.write('Vault locked.\n'); } -export function statusCommand(): void { +export async function statusCommand(): Promise { const config = getConfig(); if (!config.isInitialized()) { process.stderr.write('Vault not initialized. Run `vault init` first.\n'); @@ -131,7 +131,7 @@ export function statusCommand(): void { } const session = getSession(config); - const engine = getEngine(config); + const engine = await getEngine(config); try { const isUnlocked = session.isUnlocked(); const status = engine.getStatus(isUnlocked); @@ -159,7 +159,7 @@ export async function writeCommand( requireInit(config); const session = getSession(config); const masterKey = requireUnlock(session); - const engine = getEngine(config); + const engine = await getEngine(config); try { let content: Buffer; @@ -184,7 +184,7 @@ export async function readCommand(vaultPath: string): Promise { requireInit(config); const session = getSession(config); const masterKey = requireUnlock(session); - const engine = getEngine(config); + const engine = await getEngine(config); try { const content = await engine.readFile(vaultPath, masterKey); @@ -199,7 +199,7 @@ export async function deleteCommand(vaultPath: string): Promise { requireInit(config); const session = getSession(config); requireUnlock(session); - const engine = getEngine(config); + const engine = await getEngine(config); try { await engine.deleteFile(vaultPath); @@ -209,10 +209,10 @@ export async function deleteCommand(vaultPath: string): Promise { } } -export function listCommand(vaultPath?: string): void { +export async function listCommand(vaultPath?: string): Promise { const config = getConfig(); requireInit(config); - const engine = getEngine(config); + const engine = await getEngine(config); try { const files = engine.listFiles(vaultPath); @@ -236,10 +236,10 @@ export function listCommand(vaultPath?: string): void { } } -export function searchCommand(query: string): void { +export async function searchCommand(query: string): Promise { const config = getConfig(); requireInit(config); - const engine = getEngine(config); + const engine = await getEngine(config); try { const results = engine.searchFiles(query); @@ -260,7 +260,7 @@ export async function grepCommand(pattern: string): Promise { requireInit(config); const session = getSession(config); const masterKey = requireUnlock(session); - const engine = getEngine(config); + const engine = await getEngine(config); try { const results = await engine.grepFiles(pattern, masterKey); @@ -281,7 +281,7 @@ export async function keyAddCommand(): Promise { requireInit(config); const session = getSession(config); const masterKey = requireUnlock(session); - const engine = getEngine(config); + const engine = await getEngine(config); try { const passphrase = await promptPassphraseConfirm(); @@ -310,10 +310,10 @@ export async function keyAddCommand(): Promise { } } -export function keyListCommand(): void { +export async function keyListCommand(): Promise { const config = getConfig(); requireInit(config); - const engine = getEngine(config); + const engine = await getEngine(config); try { const keys = engine.listKeys(); @@ -331,12 +331,12 @@ export function keyListCommand(): void { } } -export function keyRevokeCommand(keyId: string): void { +export async function keyRevokeCommand(keyId: string): Promise { const config = getConfig(); requireInit(config); const session = getSession(config); requireUnlock(session); - const engine = getEngine(config); + const engine = await getEngine(config); try { engine.revokeKey(keyId); @@ -346,10 +346,10 @@ export function keyRevokeCommand(keyId: string): void { } } -export function auditCommand(options: { tail?: string; operation?: string }): void { +export async function auditCommand(options: { tail?: string; operation?: string }): Promise { const config = getConfig(); requireInit(config); - const engine = getEngine(config); + const engine = await getEngine(config); try { const limit = options.tail ? parseInt(options.tail, 10) : undefined; @@ -377,7 +377,7 @@ export function auditCommand(options: { tail?: string; operation?: string }): vo export async function mcpCommand(): Promise { const config = getConfig(); requireInit(config); - const engine = getEngine(config); + const engine = await getEngine(config); const session = getSession(config); const { createVaultMcpServer } = await import('../mcp/index.js'); diff --git a/src/cli/index.ts b/src/cli/index.ts index 1729077..8ca84c3 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -57,8 +57,8 @@ program program .command('status') .description('Show vault status') - .action(() => { - statusCommand(); + .action(async () => { + await statusCommand(); }); program @@ -91,16 +91,16 @@ program .command('list') .description('List files in the vault') .argument('[vault-path]', 'directory path to list') - .action((vaultPath?: string) => { - listCommand(vaultPath); + .action(async (vaultPath?: string) => { + await listCommand(vaultPath); }); program .command('search') .description('Search files by name or tag') .argument('', 'search query') - .action((query: string) => { - searchCommand(query); + .action(async (query: string) => { + await searchCommand(query); }); program @@ -124,16 +124,16 @@ key key .command('list') .description('List all keys') - .action(() => { - keyListCommand(); + .action(async () => { + await keyListCommand(); }); key .command('revoke') .description('Revoke a key') .argument('', 'ID of the key to revoke') - .action((keyId: string) => { - keyRevokeCommand(keyId); + .action(async (keyId: string) => { + await keyRevokeCommand(keyId); }); program @@ -141,8 +141,8 @@ program .description('View audit log') .option('--tail ', 'number of recent events to show') .option('--operation ', 'filter by operation type') - .action((options: { tail?: string; operation?: string }) => { - auditCommand(options); + .action(async (options: { tail?: string; operation?: string }) => { + await auditCommand(options); }); program diff --git a/src/vault/engine.ts b/src/vault/engine.ts index c84e233..ff00e3d 100644 --- a/src/vault/engine.ts +++ b/src/vault/engine.ts @@ -33,11 +33,17 @@ export class VaultEngine { private vaultPath: string; private filesDir: string; - constructor(vaultPath: string) { + private constructor(vaultPath: string, store: MetadataStore, audit: AuditLogger) { this.vaultPath = vaultPath; this.filesDir = path.join(vaultPath, 'files'); - this.store = new MetadataStore(path.join(vaultPath, 'vault.db')); - this.audit = new AuditLogger(path.join(vaultPath, 'audit.db')); + this.store = store; + this.audit = audit; + } + + static async open(vaultPath: string): Promise { + const store = await MetadataStore.create(path.join(vaultPath, 'vault.db')); + const audit = await AuditLogger.create(path.join(vaultPath, 'audit.db')); + return new VaultEngine(vaultPath, store, audit); } // ── Init ──────────────────────────────────────────────────────────── @@ -61,7 +67,7 @@ export class VaultEngine { const wrappedKey = await wrapMasterKeyWithPassphrase(privateKey, identity); // 4. Create engine and store initial key - const engine = new VaultEngine(vaultPath); + const engine = await VaultEngine.open(vaultPath); engine.store.insertKey({ id: randomUUID(), label: 'primary passphrase', diff --git a/src/vault/metadata.ts b/src/vault/metadata.ts index 35c672c..d49ac41 100644 --- a/src/vault/metadata.ts +++ b/src/vault/metadata.ts @@ -1,6 +1,9 @@ -import Database from 'better-sqlite3'; +import fs from 'node:fs'; +import initSqlJs, { type Database as SqlJsDatabase } from 'sql.js'; import type { FileMetadata, KeyRecord } from '../types.js'; +type SqlJsBindParams = Parameters[1]; + export interface KeyRecordFull extends KeyRecord { publicKey: string; wrappedMasterKey: Buffer; @@ -8,13 +11,20 @@ export interface KeyRecordFull extends KeyRecord { } export class MetadataStore { - private db: Database.Database; + private db: SqlJsDatabase; + private dbPath: string; + + private constructor(db: SqlJsDatabase, dbPath: string) { + this.db = db; + this.dbPath = dbPath; + } - constructor(dbPath: string) { - this.db = new Database(dbPath); - this.db.pragma('journal_mode = WAL'); + static async create(dbPath: string): Promise { + const SQL = await initSqlJs(); + const fileBuffer = fs.existsSync(dbPath) ? fs.readFileSync(dbPath) : undefined; + const db = new SQL.Database(fileBuffer); - this.db.exec(` + db.run(` CREATE TABLE IF NOT EXISTS files ( id TEXT PRIMARY KEY, vault_path TEXT UNIQUE NOT NULL, @@ -26,7 +36,7 @@ export class MetadataStore { ) `); - this.db.exec(` + db.run(` CREATE TABLE IF NOT EXISTS keys ( id TEXT PRIMARY KEY, label TEXT NOT NULL, @@ -36,17 +46,24 @@ export class MetadataStore { created_at TEXT NOT NULL ) `); + + const store = new MetadataStore(db, dbPath); + store.persist(); + return store; + } + + private persist(): void { + const data = this.db.export(); + fs.writeFileSync(this.dbPath, Buffer.from(data)); } // ── File metadata CRUD ────────────────────────────────────────────── insertFile(meta: FileMetadata): void { - this.db - .prepare( - `INSERT INTO files (id, vault_path, blob_id, size, created_at, modified_at, tags) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ) - .run( + this.db.run( + `INSERT INTO files (id, vault_path, blob_id, size, created_at, modified_at, tags) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ meta.id, meta.vaultPath, meta.blobId, @@ -54,26 +71,42 @@ export class MetadataStore { meta.createdAt.toISOString(), meta.modifiedAt.toISOString(), JSON.stringify(meta.tags), - ); + ], + ); + this.persist(); } getFile(vaultPath: string): FileMetadata | null { - const row = this.db.prepare('SELECT * FROM files WHERE vault_path = ?').get(vaultPath) as - | RawFileRow - | undefined; - return row ? toFileMetadata(row) : null; + const stmt = this.db.prepare('SELECT * FROM files WHERE vault_path = ?'); + stmt.bind([vaultPath]); + let result: FileMetadata | null = null; + if (stmt.step()) { + result = toFileMetadata(stmt.getAsObject() as unknown as RawFileRow); + } + stmt.free(); + return result; } getAllFiles(dirPath?: string): FileMetadata[] { + const results: FileMetadata[] = []; if (dirPath) { const prefix = dirPath.endsWith('/') ? dirPath : dirPath + '/'; - const rows = this.db - .prepare('SELECT * FROM files WHERE vault_path LIKE ? ORDER BY vault_path') - .all(prefix + '%') as RawFileRow[]; - return rows.map(toFileMetadata); + const stmt = this.db.prepare( + 'SELECT * FROM files WHERE vault_path LIKE ? ORDER BY vault_path', + ); + stmt.bind([prefix + '%']); + while (stmt.step()) { + results.push(toFileMetadata(stmt.getAsObject() as unknown as RawFileRow)); + } + stmt.free(); + return results; + } + const stmt = this.db.prepare('SELECT * FROM files ORDER BY vault_path'); + while (stmt.step()) { + results.push(toFileMetadata(stmt.getAsObject() as unknown as RawFileRow)); } - const rows = this.db.prepare('SELECT * FROM files ORDER BY vault_path').all() as RawFileRow[]; - return rows.map(toFileMetadata); + stmt.free(); + return results; } updateFile( @@ -98,56 +131,72 @@ export class MetadataStore { if (sets.length === 0) return; values.push(id); - this.db.prepare(`UPDATE files SET ${sets.join(', ')} WHERE id = ?`).run(...values); + this.db.run(`UPDATE files SET ${sets.join(', ')} WHERE id = ?`, values as SqlJsBindParams); + this.persist(); } deleteFile(vaultPath: string): void { - this.db.prepare('DELETE FROM files WHERE vault_path = ?').run(vaultPath); + this.db.run('DELETE FROM files WHERE vault_path = ?', [vaultPath]); + this.persist(); } getFileCount(): number { - const row = this.db.prepare('SELECT COUNT(*) AS count FROM files').get() as { - count: number; - }; + const stmt = this.db.prepare('SELECT COUNT(*) AS count FROM files'); + stmt.step(); + const row = stmt.getAsObject() as { count: number }; + stmt.free(); return row.count; } // ── Key record CRUD ───────────────────────────────────────────────── insertKey(record: KeyRecordFull): void { - this.db - .prepare( - `INSERT INTO keys (id, label, public_key, wrapped_master_key, salt, created_at) - VALUES (?, ?, ?, ?, ?, ?)`, - ) - .run( + this.db.run( + `INSERT INTO keys (id, label, public_key, wrapped_master_key, salt, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [ record.id, record.label, record.publicKey, record.wrappedMasterKey, record.salt, record.createdAt.toISOString(), - ); + ], + ); + this.persist(); } getKey(id: string): KeyRecordFull | null { - const row = this.db.prepare('SELECT * FROM keys WHERE id = ?').get(id) as RawKeyRow | undefined; - return row ? toKeyRecord(row) : null; + const stmt = this.db.prepare('SELECT * FROM keys WHERE id = ?'); + stmt.bind([id]); + let result: KeyRecordFull | null = null; + if (stmt.step()) { + result = toKeyRecord(stmt.getAsObject() as unknown as RawKeyRow); + } + stmt.free(); + return result; } getAllKeys(): KeyRecordFull[] { - const rows = this.db.prepare('SELECT * FROM keys ORDER BY created_at').all() as RawKeyRow[]; - return rows.map(toKeyRecord); + const results: KeyRecordFull[] = []; + const stmt = this.db.prepare('SELECT * FROM keys ORDER BY created_at'); + while (stmt.step()) { + results.push(toKeyRecord(stmt.getAsObject() as unknown as RawKeyRow)); + } + stmt.free(); + return results; } deleteKey(id: string): void { - this.db.prepare('DELETE FROM keys WHERE id = ?').run(id); + this.db.run('DELETE FROM keys WHERE id = ?', [id]); + this.persist(); } getKeyCount(): number { - const row = this.db.prepare('SELECT COUNT(*) AS count FROM keys').get() as { - count: number; - }; + const stmt = this.db.prepare('SELECT COUNT(*) AS count FROM keys'); + stmt.step(); + const row = stmt.getAsObject() as { count: number }; + stmt.free(); return row.count; } @@ -155,14 +204,18 @@ export class MetadataStore { searchFiles(query: string): FileMetadata[] { const pattern = `%${query}%`; - const rows = this.db - .prepare( - `SELECT * FROM files - WHERE vault_path LIKE ? OR tags LIKE ? - ORDER BY vault_path`, - ) - .all(pattern, pattern) as RawFileRow[]; - return rows.map(toFileMetadata); + const results: FileMetadata[] = []; + const stmt = this.db.prepare( + `SELECT * FROM files + WHERE vault_path LIKE ? OR tags LIKE ? + ORDER BY vault_path`, + ); + stmt.bind([pattern, pattern]); + while (stmt.step()) { + results.push(toFileMetadata(stmt.getAsObject() as unknown as RawFileRow)); + } + stmt.free(); + return results; } close(): void { @@ -186,7 +239,7 @@ interface RawKeyRow { id: string; label: string; public_key: string; - wrapped_master_key: Buffer; + wrapped_master_key: Uint8Array; salt: string; created_at: string; } diff --git a/test/unit/audit.test.ts b/test/unit/audit.test.ts index 0113326..b55f46e 100644 --- a/test/unit/audit.test.ts +++ b/test/unit/audit.test.ts @@ -9,9 +9,9 @@ describe('AuditLogger', () => { let logger: AuditLogger; let tempDir: string; - beforeEach(() => { + beforeEach(async () => { tempDir = mkdtempSync(join(tmpdir(), 'vault-audit-')); - logger = new AuditLogger(join(tempDir, 'audit.db')); + logger = await AuditLogger.create(join(tempDir, 'audit.db')); }); afterEach(() => { @@ -129,9 +129,17 @@ describe('AuditLogger', () => { it('NEVER stores content — only operation metadata', async () => { // Open a raw database connection to inspect the schema - const Database = (await import('better-sqlite3')).default; - const db = new Database(join(tempDir, 'audit.db'), { readonly: true }); - const columns = db.prepare("PRAGMA table_info('audit_log')").all() as Array<{ name: string }>; + const initSqlJs = (await import('sql.js')).default; + const SQL = await initSqlJs(); + const { readFileSync } = await import('node:fs'); + const fileBuffer = readFileSync(join(tempDir, 'audit.db')); + const db = new SQL.Database(fileBuffer); + const stmt = db.prepare("PRAGMA table_info('audit_log')"); + const columns: Array<{ name: string }> = []; + while (stmt.step()) { + columns.push(stmt.getAsObject() as { name: string }); + } + stmt.free(); const columnNames = columns.map((c) => c.name); // Only these columns should exist @@ -149,29 +157,29 @@ describe('AuditLogger', () => { db.close(); }); - it('handles concurrent access gracefully (WAL mode)', () => { + it('handles sequential access gracefully', async () => { const dbPath = join(tempDir, 'audit.db'); - const logger2 = new AuditLogger(dbPath); + // Write from first logger and close + logger.logEvent({ operation: OperationType.READ, targetPath: 'from-logger1.txt' }); + logger.close(); + + // Open second logger — should see the first event on disk + const logger2 = await AuditLogger.create(dbPath); try { - // Write from both loggers - logger.logEvent({ operation: OperationType.READ, targetPath: 'from-logger1.txt' }); logger2.logEvent({ operation: OperationType.WRITE, targetPath: 'from-logger2.txt' }); - // Read from both and verify no data corruption - const events1 = logger.getEvents(); - const events2 = logger2.getEvents(); + const events = logger2.getEvents(); + expect(events).toHaveLength(2); - expect(events1).toHaveLength(2); - expect(events2).toHaveLength(2); - - const paths1 = events1.map((e) => e.targetPath).sort(); - const paths2 = events2.map((e) => e.targetPath).sort(); - expect(paths1).toEqual(['from-logger1.txt', 'from-logger2.txt']); - expect(paths2).toEqual(['from-logger1.txt', 'from-logger2.txt']); + const paths = events.map((e) => e.targetPath).sort(); + expect(paths).toEqual(['from-logger1.txt', 'from-logger2.txt']); } finally { logger2.close(); } + + // Re-create logger for afterEach cleanup + logger = await AuditLogger.create(dbPath); }); it('assigns sequential ids to events', () => { diff --git a/test/unit/vault.test.ts b/test/unit/vault.test.ts index ef77d52..2d1be3f 100644 --- a/test/unit/vault.test.ts +++ b/test/unit/vault.test.ts @@ -26,9 +26,9 @@ describe('MetadataStore', () => { let tmpDir: string; let store: MetadataStore; - beforeEach(() => { + beforeEach(async () => { tmpDir = makeTempDir(); - store = new MetadataStore(path.join(tmpDir, 'vault.db')); + store = await MetadataStore.create(path.join(tmpDir, 'vault.db')); }); afterEach(() => { From aaedee1cb21c91d8cb86be441ba1eb43b01fd8ce Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 21:25:21 -0700 Subject: [PATCH 22/23] fix: wire WORKSPACE_VAULT_CONFIG_DIR env var and isolate CLI tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/config/index.ts | 3 +++ test/unit/cli.test.ts | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/config/index.ts b/src/config/index.ts index e4c0c4f..21a2502 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -10,6 +10,9 @@ const CONFIG_FILE_NAME = 'config.json'; const SESSION_FILE_NAME = 'session'; function getConfigDir(): string { + const envDir = process.env['WORKSPACE_VAULT_CONFIG_DIR']; + if (envDir) return envDir; + if (process.platform === 'win32') { return path.join( process.env['APPDATA'] || path.join(os.homedir(), 'AppData', 'Roaming'), diff --git a/test/unit/cli.test.ts b/test/unit/cli.test.ts index b695384..16923a1 100644 --- a/test/unit/cli.test.ts +++ b/test/unit/cli.test.ts @@ -1,15 +1,20 @@ import { describe, it, expect } from 'vitest'; import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; const CLI_PATH = path.resolve('dist/cli/index.js'); +// Use an isolated config dir so tests don't depend on host machine state +const TEST_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'vault-cli-test-')); + function run(args: string[]): { stdout: string; stderr: string; exitCode: number } { try { const stdout = execFileSync('node', [CLI_PATH, ...args], { encoding: 'utf-8', timeout: 10_000, - env: { ...process.env, NODE_NO_WARNINGS: '1' }, + env: { ...process.env, NODE_NO_WARNINGS: '1', WORKSPACE_VAULT_CONFIG_DIR: TEST_CONFIG_DIR }, }); return { stdout, stderr: '', exitCode: 0 }; } catch (err) { From f1c29419fbdf45c50350755b751be09101e5468f Mon Sep 17 00:00:00 2001 From: Jeff Omhover Date: Wed, 22 Apr 2026 21:37:13 -0700 Subject: [PATCH 23/23] fix: address PR review feedback - Reject absolute paths in validateVaultPath - Use masterKey to derive public key in writeFile instead of ignoring it - Fix unwrapMasterKey detection: check AGE-SECRET-KEY- prefix first - Windows-safe session file rename with pre-delete fallback - Distinguish filename vs tag matches in searchFiles - Validate regex pattern in grepFiles, reject null bytes and long patterns - Fix CLI test to capture stderr properly using spawnSync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/crypto/keys.ts | 16 ++++++++-------- src/security/paths.ts | 7 ++++++- src/session/manager.ts | 19 ++++++++++++++++++- src/vault/engine.ts | 39 +++++++++++++++++++++++++++++++-------- test/unit/cli.test.ts | 32 +++++++++++++------------------- 5 files changed, 76 insertions(+), 37 deletions(-) diff --git a/src/crypto/keys.ts b/src/crypto/keys.ts index 4c2e941..17b6635 100644 --- a/src/crypto/keys.ts +++ b/src/crypto/keys.ts @@ -62,19 +62,19 @@ export async function wrapMasterKey( /** * Unwrap (decrypt) a master key's private key using an identity string. - * If the identity starts with "passphrase:", it was created via - * passphraseToIdentity and we use passphrase-based decryption. + * Native age identities use the AGE-SECRET-KEY-... format. Passphrase-based + * identities are the opaque combined passphrase+salt strings returned by + * passphraseToIdentity (formatted as `${passphrase}:${salt}`), and are + * decrypted with passphrase-based decryption. */ export async function unwrapMasterKey(wrappedKey: Buffer, identity: string): Promise { try { - // If this is a passphrase-derived identity (from passphraseToIdentity), - // use passphrase-based decryption. - if (identity.includes(':')) { - const plaintext = await decryptWithPassphrase(wrappedKey, identity); + if (identity.startsWith('AGE-SECRET-KEY-')) { + const plaintext = await decrypt(wrappedKey, identity); return plaintext.toString('utf-8'); } - // Otherwise it's a native age identity (AGE-SECRET-KEY-1...) - const plaintext = await decrypt(wrappedKey, identity); + // Passphrase-derived identity from passphraseToIdentity + const plaintext = await decryptWithPassphrase(wrappedKey, identity); return plaintext.toString('utf-8'); } catch (err) { if (err instanceof InvalidKeyError) throw err; diff --git a/src/security/paths.ts b/src/security/paths.ts index 4a8176c..471e6c9 100644 --- a/src/security/paths.ts +++ b/src/security/paths.ts @@ -19,7 +19,12 @@ export function validateVaultPath(userPath: string, vaultRoot: string): string { throw new PathTraversalError('Path contains null bytes'); } - // 2. Normalize the vault root to a resolved absolute path + // 2. Reject absolute paths — vault paths must be relative + if (path.isAbsolute(userPath)) { + throw new PathTraversalError(`Path "${userPath}" must be relative to vault root`); + } + + // 3. Normalize the vault root to a resolved absolute path const resolvedRoot = path.resolve(vaultRoot); // 3. Join vaultRoot + userPath, resolve to absolute diff --git a/src/session/manager.ts b/src/session/manager.ts index 42d2122..089c2a6 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -34,7 +34,24 @@ export class SessionManager { const tempPath = `${this.sessionPath}.tmp`; fs.writeFileSync(tempPath, JSON.stringify(data), 'utf-8'); setRestrictivePermissions(tempPath, 'file'); - fs.renameSync(tempPath, this.sessionPath); + + try { + if (process.platform === 'win32' && fs.existsSync(this.sessionPath)) { + try { + fs.unlinkSync(this.sessionPath); + } catch { + // Let renameSync surface the real error + } + } + fs.renameSync(tempPath, this.sessionPath); + } catch (err) { + try { + if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); + } catch { + // Ignore temp cleanup failures + } + throw err; + } } /** diff --git a/src/vault/engine.ts b/src/vault/engine.ts index ff00e3d..1e2608c 100644 --- a/src/vault/engine.ts +++ b/src/vault/engine.ts @@ -90,7 +90,7 @@ export class VaultEngine { async writeFile( vaultFilePath: string, content: Buffer, - _masterKey: string, + masterKey: string, tags?: string[], ): Promise { this.validatePath(vaultFilePath); @@ -99,7 +99,9 @@ export class VaultEngine { throw new FileAlreadyExistsError(`File already exists: ${vaultFilePath}`); } - const publicKey = this.getMasterPublicKey(); + // Encrypt using the master public key derived from the provided master key + const { identityToRecipient } = await import('age-encryption'); + const publicKey = await identityToRecipient(masterKey); const encrypted = await encrypt(content, [publicKey]); const blobId = randomUUID(); @@ -174,17 +176,38 @@ export class VaultEngine { searchFiles(query: string): SearchResult[] { const files = this.store.searchFiles(query); this.audit.logEvent({ operation: OperationType.SEARCH, success: true }); - return files.map((f) => ({ - vaultPath: f.vaultPath, - matchType: 'filename' as const, - matchedValue: f.vaultPath, - })); + const lowerQuery = query.toLowerCase(); + return files.map((f) => { + const tagMatch = f.tags.find((t) => t.toLowerCase().includes(lowerQuery)); + if (tagMatch) { + return { vaultPath: f.vaultPath, matchType: 'tag' as const, matchedValue: tagMatch }; + } + return { + vaultPath: f.vaultPath, + matchType: 'filename' as const, + matchedValue: path.basename(f.vaultPath), + }; + }); } async grepFiles(pattern: string, masterKey: string): Promise { + // Validate pattern + if (pattern.includes('\0')) { + throw new Error('Search pattern contains null bytes'); + } + if (pattern.length > 1000) { + throw new Error('Search pattern too long (max 1000 characters)'); + } + + let regex: RegExp; + try { + regex = new RegExp(pattern, 'gi'); + } catch { + throw new Error(`Invalid search pattern: ${pattern}`); + } + const allFiles = this.store.getAllFiles(); const results: GrepResult[] = []; - const regex = new RegExp(pattern, 'gi'); for (const file of allFiles) { try { diff --git a/test/unit/cli.test.ts b/test/unit/cli.test.ts index 16923a1..f7ca2b6 100644 --- a/test/unit/cli.test.ts +++ b/test/unit/cli.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { execFileSync } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -10,21 +10,16 @@ const CLI_PATH = path.resolve('dist/cli/index.js'); const TEST_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'vault-cli-test-')); function run(args: string[]): { stdout: string; stderr: string; exitCode: number } { - try { - const stdout = execFileSync('node', [CLI_PATH, ...args], { - encoding: 'utf-8', - timeout: 10_000, - env: { ...process.env, NODE_NO_WARNINGS: '1', WORKSPACE_VAULT_CONFIG_DIR: TEST_CONFIG_DIR }, - }); - return { stdout, stderr: '', exitCode: 0 }; - } catch (err) { - const e = err as { stdout?: string; stderr?: string; status?: number }; - return { - stdout: e.stdout ?? '', - stderr: e.stderr ?? '', - exitCode: e.status ?? 1, - }; - } + const result = spawnSync('node', [CLI_PATH, ...args], { + encoding: 'utf-8', + timeout: 10_000, + env: { ...process.env, NODE_NO_WARNINGS: '1', WORKSPACE_VAULT_CONFIG_DIR: TEST_CONFIG_DIR }, + }); + return { + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + exitCode: result.status ?? 1, + }; } describe('CLI command registration', () => { @@ -62,9 +57,8 @@ describe('CLI command registration', () => { describe('CLI error handling', () => { it('should fail gracefully for status when vault is not initialized', () => { const { stderr, exitCode } = run(['status']); - // Status prints a message but doesn't exit 1 when not initialized - expect(stderr + '').toContain(''); - expect(typeof exitCode).toBe('number'); + expect(exitCode).toBe(0); + expect(stderr).toContain('not initialized'); }); it('should fail gracefully for lock when vault is not initialized', () => {