From b5dbee8396be09fe53f4d20902b042f1e3d6e425 Mon Sep 17 00:00:00 2001 From: Kauan Guesser Date: Sun, 23 Aug 2026 21:45:45 -0300 Subject: [PATCH] feat: add protected storage workspace bridge --- .changeset/protected-storage-workspaces.md | 10 + README.md | 138 +- SECURITY.md | 47 + package.json | 10 + pnpm-lock.yaml | 1020 +++++++++++++ scripts/check-package.mjs | 23 + scripts/test-packed-consumers.mjs | 133 +- .../cipher-engine-adapter.ts | 100 ++ src/storage-workspace/index.ts | 13 + .../protected-storage-workspace.spec.ts | 801 ++++++++++ .../protected-storage-workspace.ts | 1329 +++++++++++++++++ src/storage-workspace/types.ts | 93 ++ tsdown.config.ts | 2 + 13 files changed, 3699 insertions(+), 20 deletions(-) create mode 100644 .changeset/protected-storage-workspaces.md create mode 100644 src/storage-workspace/cipher-engine-adapter.ts create mode 100644 src/storage-workspace/index.ts create mode 100644 src/storage-workspace/protected-storage-workspace.spec.ts create mode 100644 src/storage-workspace/protected-storage-workspace.ts create mode 100644 src/storage-workspace/types.ts diff --git a/.changeset/protected-storage-workspaces.md b/.changeset/protected-storage-workspaces.md new file mode 100644 index 0000000..38a6647 --- /dev/null +++ b/.changeset/protected-storage-workspaces.md @@ -0,0 +1,10 @@ +--- +"@nestm/crypto": minor +--- + +Add the optional `@nestm/crypto/storage-workspace` bridge for composing a +capability-scoped `@nestm/storage` workspace with strict, authenticated body +encryption. The bridge keeps storage and crypto policies application-owned, +binds ciphertext to stable workspace scope and canonical path context, exposes +only a non-secret protection descriptor, and leaves the framework-neutral +crypto core independent of storage. diff --git a/README.md b/README.md index 77ef5c4..1fdc592 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ Authenticated, versioned encryption for Node.js and NestJS 12. The package provides an AES-256-GCM core, envelope key providers, explicit field traversal, cloud KMS adapters, a -fail-closed bridge to `@nestm/tenant`, and opt-in HTTP and Prisma field adapters. +fail-closed bridge to `@nestm/tenant`, an optional protected-workspace bridge to `@nestm/storage`, +and opt-in HTTP and Prisma field adapters. > This package is an alpha. Pin an exact version, retain every key needed by stored ciphertext, and > test rotation and recovery before using it for production data. @@ -24,6 +25,9 @@ pnpm add @nestjs/common@next @nestjs/core@next reflect-metadata rxjs # Optional tenant bridge pnpm add @nestm/tenant +# Optional protected StorageWorkspace bridge (pin prerelease integrations exactly) +pnpm add @nestm/storage@0.1.0-alpha.9 + # Optional HTTP DTO adapter pnpm add class-transformer class-validator @@ -33,11 +37,11 @@ pnpm add @google-cloud/kms pnpm add @azure/core-auth @azure/keyvault-keys ``` -Cloud SDKs, NestJS, `@nestm/tenant`, and `class-transformer` are optional peers. `class-validator` is -application-owned and needed only when the app uses Nest's `ValidationPipe` validation. The Prisma -adapter is schema-agnostic and uses the consuming application's Prisma client, so it adds no Prisma -dependency. A consumer that imports only `@nestm/crypto/core` installs none of the optional -integrations. +Cloud SDKs, NestJS, `@nestm/storage`, `@nestm/tenant`, and `class-transformer` are optional peers. +`class-validator` is application-owned and needed only when the app uses Nest's `ValidationPipe` +validation. The Prisma adapter is schema-agnostic and uses the consuming application's Prisma client, +so it adds no Prisma dependency. A consumer that imports only `@nestm/crypto/core` installs none of +the optional integrations. ## NestJS quick start @@ -179,6 +183,101 @@ An authenticated full frame may be emitted before a later frame or EOF fails. A that authenticated prefix and abort on failure, but agents, previews, archive/skill validation, and other side-effecting consumers must stage all plaintext until `verification` resolves. +## Protected storage workspaces + +`@nestm/crypto/storage-workspace` composes an already-authorized `StorageWorkspace` from +`@nestm/storage/workspace` with an application-supplied authenticated cipher. The adapter imports +both libraries only from this optional subpath: `@nestm/crypto/core` and the root entry point remain +independent of storage, while `@nestm/storage` remains independent of key providers and crypto +policy. + +The built-in `createCipherEngineWorkspaceCipher()` adapter uses `CipherEngine` and the bounded +`nmc1` format. It strictly encrypts the logical metadata record and body independently, and it rejects +plaintext, malformed records, wrong scope/policy/path context, and unauthenticated envelopes on read. +The backing workspace stores one versioned outer JSON record whose `metadata` and `content` members +are `nmc1` envelopes; it never receives the original body or logical content type. + +```ts +import type { StorageWorkspace } from "@nestm/storage/workspace"; +import type { CipherEngine } from "@nestm/crypto/core"; +import { + createCipherEngineWorkspaceCipher, + protectStorageWorkspace, +} from "@nestm/crypto/storage-workspace"; + +declare const mountedWorkspace: StorageWorkspace; +declare const engine: CipherEngine; +declare const canonicalOrganizationId: string; +declare const canonicalWorkspaceId: string; + +const logicalLimits = { + ...mountedWorkspace.limits, + maxReadBytes: 1024 * 1024, + maxWriteBytes: 1024 * 1024, +}; +const protectedWorkspace = protectStorageWorkspace({ + storage: mountedWorkspace, + cipher: createCipherEngineWorkspaceCipher(engine, { + allowedProviders: ["workspace-current", "workspace-previous"], + }), + scopeContext: `organization:${canonicalOrganizationId}/workspace:${canonicalWorkspaceId}`, + policyRevision: "artifact-body:v1", + limits: logicalLimits, + // This is a separate ceiling for the expanded outer record held by the backing workspace. + maxCiphertextBytes: 2 * 1024 * 1024, + // Explicitly accepts that the backing provider can search visible object paths. + pathSearch: "provider-visible", +}); + +await protectedWorkspace.writeFile("reports/result.md", "# Verified\n", { + mode: "create", + contentType: "text/markdown; charset=utf-8", +}); +const restored = await protectedWorkspace.readText("reports/result.md"); +``` + +On the initial protected view, the path, page, search, cursor, and cursor-TTL limits must match the +already-mounted backing workspace because that capability owns their enforcement. Logical +`maxReadBytes` and `maxWriteBytes` may be lower. A derived `mount()` creates a correspondingly narrowed +backing capability, so every child limit can be reduced safely. Configure `CipherEngine.maxPayloadBytes` +to cover the larger logical read/write ceiling and size `maxCiphertextBytes` for `nmc1` plus outer-record +expansion; a plaintext value within its logical limit can still exceed an undersized physical ceiling. + +`scopeContext` and `policyRevision` are authenticated domain inputs. Derive the scope from trusted, +canonical application context and keep both values reproducible for the lifetime of stored objects. +Do not change `policyRevision` merely because the active wrapping key rotates; use an explicit +read/migrate policy when changing the authenticated policy domain. The cipher adapter does not take +ownership of `CipherEngine`, so the application that created the engine must close it. + +`AuthenticatedWorkspaceCipher` is a trusted composition contract for alternative implementations. +Implementations must remain bounded, authenticate both supplied contexts, honor cancellation, and not +retain borrowed plaintext or context buffers after a call settles. The built-in adapter supplies those +properties through `CipherEngine`; registered cipher algorithms and key providers remain part of the +application's crypto trust boundary. + +The canonical logical path and record purpose are also bound automatically, so copying a raw backing +record to another path does not produce valid plaintext there. Protected `copyFile()` and `moveFile()` +therefore fail with `NOT_SUPPORTED`. An authorized application workflow must read/authenticate the +source and write a newly encrypted destination; a move may then conditionally delete the source, with +the same multi-object transactional caveats as any application-managed move. + +The bridge deliberately does not hide all storage metadata. Object paths, directory shape, existence, +ciphertext length, provider ETag/timestamps, and access patterns remain visible to the backing store. +Path search is disabled unless `pathSearch: "provider-visible"` explicitly accepts that leakage. +Applications such as artifact catalogs should keep only deliberately searchable fields—safe IDs, +titles, descriptions, status, and other chosen projections—in their own authorized catalog and store +the sensitive artifact body through this protected workspace. The bridge does not encrypt arbitrary +database columns or decide which catalog fields are safe. + +Protected list, stat, and enabled search operations authenticate file metadata before returning it. +Synthetic directories, provider-level existence, cursors, and access patterns remain storage signals, +and the file body is authenticated only by a protected read. + +`protectedWorkspace.protection` exposes a frozen, non-secret integration descriptor containing the +outer format/version, `nmc1` envelope, path binding, metadata/body protection, path-search mode, and +policy revision. It describes the configured boundary; it does not reveal provider keys, storage +coordinates, plaintext, or ciphertext. + ## Operations Both `CipherService` and `CipherEngine` provide: @@ -651,19 +750,20 @@ plaintext with `@nestm/crypto`; relabeling or importing the old ciphertext is no ## Entry points -| Entry point | Purpose | -| ---------------------------- | ----------------------------------------------------------------------------- | -| `@nestm/crypto/core` | AES-256-GCM engine, envelope codec, local AES KEK ring, contracts, and errors | -| `@nestm/crypto/files` | NMF1 bounded-memory streaming file encryption with detached wrapped keys | -| `@nestm/crypto/fields` | purpose-decorated class traversal | -| `@nestm/crypto/tenant` | tenant-bound cipher and field services | -| `@nestm/crypto/http` | request-encryption pipe and opt-in response-decryption interceptor | -| `@nestm/crypto/prisma` | schema-agnostic tenant Prisma write processor | -| `@nestm/crypto/key-wrap/rsa` | RSA-OAEP-SHA256 wrapping with named public/private keys | -| `@nestm/crypto/kms/aws` | AWS KMS data-key generation and decrypt | -| `@nestm/crypto/kms/gcp` | Google Cloud KMS encrypt/decrypt with AAD and CRC32C validation | -| `@nestm/crypto/kms/azure` | Azure Key Vault/Managed HSM wrap/unwrap | -| `@nestm/crypto/testing` | dependency-free deterministic nonce source for tests | +| Entry point | Purpose | +| --------------------------------- | ----------------------------------------------------------------------------- | +| `@nestm/crypto/core` | AES-256-GCM engine, envelope codec, local AES KEK ring, contracts, and errors | +| `@nestm/crypto/files` | NMF1 bounded-memory streaming file encryption with detached wrapped keys | +| `@nestm/crypto/storage-workspace` | strict authenticated body/metadata protection for `StorageWorkspace` | +| `@nestm/crypto/fields` | purpose-decorated class traversal | +| `@nestm/crypto/tenant` | tenant-bound cipher and field services | +| `@nestm/crypto/http` | request-encryption pipe and opt-in response-decryption interceptor | +| `@nestm/crypto/prisma` | schema-agnostic tenant Prisma write processor | +| `@nestm/crypto/key-wrap/rsa` | RSA-OAEP-SHA256 wrapping with named public/private keys | +| `@nestm/crypto/kms/aws` | AWS KMS data-key generation and decrypt | +| `@nestm/crypto/kms/gcp` | Google Cloud KMS encrypt/decrypt with AAD and CRC32C validation | +| `@nestm/crypto/kms/azure` | Azure Key Vault/Managed HSM wrap/unwrap | +| `@nestm/crypto/testing` | dependency-free deterministic nonce source for tests | Adapters accept caller-owned SDK clients as well as configuration-created clients. Caller-owned clients are never closed; owned clients are closed or released where the SDK exposes that lifecycle. Each cloud diff --git a/SECURITY.md b/SECURITY.md index d82b68b..ab78156 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -78,6 +78,53 @@ Local AES and RSA providers shift key custody into the application. Load their s secret source, keep active wrapping keys separate from legacy decrypt-only keys, and retain old keys until all dependent ciphertext has been re-encrypted and verified. +## Protected-storage-workspace boundary + +`@nestm/crypto/storage-workspace` is an optional composition layer over an already-mounted, +application-authorized `StorageWorkspace`. It does not select a provider, bucket, filesystem root, +tenant, mount prefix, or key route. The application remains responsible for constructing the storage +capability and authenticated cipher independently and for deriving `scopeContext` and +`policyRevision` from stable, trusted policy state. + +The bridge stores one strict, versioned outer JSON record. Logical metadata and file content are +separate `nmc1` envelopes with distinct authenticated purposes. Their AAD binds the workspace scope, +policy revision, canonical relative path, record version, and purpose. Plaintext records, unknown +members or versions, malformed envelopes, swapped metadata/content envelopes, and a raw record copied +to another path fail closed during protected reads. Protected copy and move are intentionally denied. +An authorized application must read/authenticate the source, write/re-encrypt the destination, and—if +moving—conditionally delete the source while accounting for the lack of a cross-object transaction. +Raw backing-store mutations bypass the bridge and can cause an availability failure. + +This boundary encrypts the logical body and its protected metadata record, not every storage signal. +The backing store still observes physical keys, directory shape, object existence, ciphertext sizes, +provider ETags/timestamps, operation timing, and access patterns. Provider-visible path search is an +explicit opt-in; keep it disabled when path disclosure or equality is unacceptable. Applications that +need searchable titles, descriptions, statuses, or other projections should maintain a separate, +authorized catalog and choose those plaintext fields deliberately. This package neither encrypts +arbitrary catalog/database fields nor decides which projections are safe to expose. + +File metadata returned by protected list, stat, and search operations is authenticated. Synthetic +directories, provider-level existence, and continuation behavior still describe backing-store state, +and none of those operations proves that a body authenticates; body authentication occurs on protected +read. The read-only protection descriptor reports the configured bridge contract but is not a +remote-attestation mechanism and must not be used as authorization evidence. + +The `CipherEngine` workspace adapter is buffered. Configure independent logical plaintext and outer +ciphertext ceilings, ensure the backing workspace can hold the expanded record, and propagate abort +signals and deadlines. Use NMF1 `@nestm/crypto/files` behind a purpose-built integration when immutable +multi-gigabyte streaming objects are required; do not weaken the bounded `nmc1` bridge with an +application-side plaintext fallback. As with the core envelope, deletion and same-path rollback to an +older valid record are not prevented; applications needing freshness must enforce it in an +authenticated catalog or version policy. + +At initial composition, non-buffer limits must match the already-mounted backing workspace; only the +logical read/write limits may be lower. Derived mounts create narrowed backing capabilities and may +reduce every limit. The injected `AuthenticatedWorkspaceCipher` is trusted to enforce its own bounds, +honor the operation signal, and stop using borrowed buffers when its promise settles. Prefer the built-in +`CipherEngine` adapter unless an alternative has equivalent conformance coverage. Short invalid paths +are byte-bounded before crypto but receive their complete canonical path validation from the backing +workspace, so a rejected create/overwrite may perform cryptographic work without persisting an object. + ## Tenant boundary `@nestm/crypto/tenant` derives the canonical tenant only from `TENANT_CONTEXT_READER`. Its ordinary diff --git a/package.json b/package.json index 31a843e..4d76326 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,11 @@ "import": "./dist/files/index.mjs", "default": "./dist/files/index.mjs" }, + "./storage-workspace": { + "types": "./dist/storage-workspace/index.d.mts", + "import": "./dist/storage-workspace/index.mjs", + "default": "./dist/storage-workspace/index.mjs" + }, "./fields": { "types": "./dist/fields/index.d.mts", "import": "./dist/fields/index.mjs", @@ -144,6 +149,7 @@ "@azure/core-auth": ">=1.11.0 <2", "@azure/keyvault-keys": ">=4.10.2 <5", "@google-cloud/kms": ">=5.7.0 <6", + "@nestm/storage": ">=0.1.0-alpha.9 <0.2.0", "@nestm/tenant": ">=0.1.0-alpha.3 <0.2.0", "@nestjs/common": "^12.0.0-alpha.5", "@nestjs/core": "^12.0.0-alpha.5", @@ -164,6 +170,9 @@ "@google-cloud/kms": { "optional": true }, + "@nestm/storage": { + "optional": true + }, "@nestm/tenant": { "optional": true }, @@ -190,6 +199,7 @@ "@azure/keyvault-keys": "4.10.2", "@changesets/cli": "2.31.1", "@google-cloud/kms": "5.7.0", + "@nestm/storage": "0.1.0-alpha.9", "@nestm/tenant": "0.1.0-alpha.5", "@nestjs/common": "12.0.0-alpha.5", "@nestjs/core": "12.0.0-alpha.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc14a12..8f01205 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@nestjs/testing': specifier: 12.0.0-alpha.5 version: 12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestm/storage': + specifier: 0.1.0-alpha.9 + version: 0.1.0-alpha.9(@azure/core-auth@1.11.0(supports-color@7.2.0))(@nestjs/common@12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(reflect-metadata@0.2.2)(rxjs@7.8.2))(google-auth-library@10.5.0(supports-color@7.2.0))(hono@4.13.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0)(zod@4.4.3) '@nestm/tenant': specifier: 0.1.0-alpha.5 version: 0.1.0-alpha.5(@nestjs/common@12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/testing@12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(reflect-metadata@0.2.2)(rxjs@7.8.2)))(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -300,6 +303,12 @@ packages: engines: {node: '>=6'} hasBin: true + '@hono/node-server@2.1.1': + resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -339,6 +348,16 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@nestjs/common@12.0.0-alpha.5': resolution: {integrity: sha512-MB2bFWi/GE5TYIR1Z6Y1gp7iRk4xm9+zOlFlV5+5T3j7LNA6AR25YqibUdbgRkAbpklrHIxxj/gEFAw9yAeIwQ==} peerDependencies: @@ -383,6 +402,42 @@ packages: '@nestjs/platform-express': optional: true + '@nestm/storage@0.1.0-alpha.9': + resolution: {integrity: sha512-Hw3t+VhykWhowwrh68CIPgCiq/6TCnG8+4FML4YBsrRtRhImcrIQnUPA0aLbmMdVkRQOXv0Xlpot1lMh3txG/g==} + engines: {node: '>=22.12.0'} + peerDependencies: + '@aws-sdk/client-s3': ^3.919.0 + '@aws-sdk/lib-storage': ^3.700.0 + '@aws-sdk/s3-presigned-post': ^3.700.0 + '@aws-sdk/s3-request-presigner': ^3.700.0 + '@nestjs/common': ^12.0.0-alpha.5 + '@nestjs/core': ^12.0.0-alpha.5 + ai: '>=7.0.0 <8.0.0' + reflect-metadata: ^0.2.2 + rxjs: ^7.8.1 + zod: ^4.1.8 + peerDependenciesMeta: + '@aws-sdk/client-s3': + optional: true + '@aws-sdk/lib-storage': + optional: true + '@aws-sdk/s3-presigned-post': + optional: true + '@aws-sdk/s3-request-presigner': + optional: true + '@nestjs/common': + optional: true + '@nestjs/core': + optional: true + ai: + optional: true + reflect-metadata: + optional: true + rxjs: + optional: true + zod: + optional: true + '@nestm/tenant@0.1.0-alpha.5': resolution: {integrity: sha512-Ac/mxgnuLbAnDqBac85coK0ihDxAnWgXYwxuFB13je4TBNripJCWyJiiufwA8J2gcarGy7uo8Kscsvy8bOMDaQ==} engines: {node: '>=22.13.0'} @@ -1064,10 +1119,25 @@ packages: '@yuku-toolchain/types@0.8.4': resolution: {integrity: sha512-p7JE8flrj7ijZ/qLjHi4UwKqMarMD6zumbKXhrjp2I2iLJOuTYiQyci2U36VlXcUlNyzsY7E/mLnKCHotbzJVw==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1116,6 +1186,9 @@ packages: ast-v8-to-istanbul@1.0.5: resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + aws4fetch@1.0.20: + resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1129,6 +1202,10 @@ packages: bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} @@ -1142,10 +1219,22 @@ packages: buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1198,13 +1287,41 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1225,6 +1342,10 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -1246,6 +1367,10 @@ packages: oxc-resolver: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + duplexify@4.1.3: resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} @@ -1255,6 +1380,9 @@ packages: ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1268,6 +1396,10 @@ packages: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -1279,13 +1411,28 @@ packages: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -1294,16 +1441,41 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.6.2: + resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -1311,6 +1483,9 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1334,10 +1509,158 @@ packages: resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} engines: {node: '>=20'} + files-sdk@2.2.3: + resolution: {integrity: sha512-1xCCh5yBhNwVfpeLKSaU7ejmh2kesw9mnU9dus0rw9EzNuNmcIuECZ91DlD0YsEP5ojXlPaPB8G7pQ25tqdebQ==} + hasBin: true + peerDependencies: + '@anthropic-ai/claude-agent-sdk': ^0.3.0 + '@aws-sdk/client-s3': ^3.700.0 + '@aws-sdk/lib-storage': ^3.700.0 + '@aws-sdk/s3-presigned-post': ^3.700.0 + '@aws-sdk/s3-request-presigner': ^3.700.0 + '@azure/core-auth': ^1.9.0 + '@azure/identity': ^4.5.0 + '@azure/storage-blob': ^12.26.0 + '@bunny.net/storage-sdk': ^0.3.1 + '@google-cloud/storage': ^7.19.0 + '@googleapis/drive': ^20.0.0 + '@microsoft/microsoft-graph-client': ^3.0.7 + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@netlify/blobs': ^10.7.4 + '@openai/agents': ^0.11.0 + '@opentelemetry/api': ^1.9.0 + '@supabase/storage-js': ^2.105.4 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@vercel/blob': ^2.4.0 + ai: ^6.0.0 + astro: ^4.0.0 || ^5.0.0 || ^6.0.0 + basic-ftp: ^6.0.1 + box-typescript-sdk-gen: ^1.19.1 + cloudinary: ^2.10.0 + convex: ^1.16.0 + disk: ^0.8.18 + dropbox: ^10.34.0 + fastify: ^4.0.0 || ^5.0.0 + firebase-admin: ^14.1.0 + google-auth-library: ^10.0.0 + h3: ^1.0.0 + hono: ^4.0.0 + koa: ^2.0.0 || ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + node-appwrite: ^26.0.0 + openai: ^6.0.0 + pocketbase: ^0.27.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + ssh2-sftp-client: ^12.1.1 + svelte: ^4.0.0 || ^5.0.0 + uploadthing: ^7 + vue: ^3.4.0 + webdav: ^5.10.0 + zod: ^3.23.0 || ^4.0.0 + peerDependenciesMeta: + '@anthropic-ai/claude-agent-sdk': + optional: true + '@aws-sdk/client-s3': + optional: true + '@aws-sdk/lib-storage': + optional: true + '@aws-sdk/s3-presigned-post': + optional: true + '@aws-sdk/s3-request-presigner': + optional: true + '@azure/core-auth': + optional: true + '@azure/identity': + optional: true + '@azure/storage-blob': + optional: true + '@bunny.net/storage-sdk': + optional: true + '@google-cloud/storage': + optional: true + '@googleapis/drive': + optional: true + '@microsoft/microsoft-graph-client': + optional: true + '@nestjs/common': + optional: true + '@netlify/blobs': + optional: true + '@openai/agents': + optional: true + '@opentelemetry/api': + optional: true + '@supabase/storage-js': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@vercel/blob': + optional: true + ai: + optional: true + astro: + optional: true + basic-ftp: + optional: true + box-typescript-sdk-gen: + optional: true + cloudinary: + optional: true + convex: + optional: true + disk: + optional: true + dropbox: + optional: true + fastify: + optional: true + firebase-admin: + optional: true + google-auth-library: + optional: true + h3: + optional: true + hono: + optional: true + koa: + optional: true + next: + optional: true + node-appwrite: + optional: true + openai: + optional: true + pocketbase: + optional: true + react: + optional: true + react-dom: + optional: true + ssh2-sftp-client: + optional: true + svelte: + optional: true + uploadthing: + optional: true + vue: + optional: true + webdav: + optional: true + zod: + optional: true + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -1350,6 +1673,14 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -1363,6 +1694,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gaxios@7.1.3: resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} engines: {node: '>=18'} @@ -1379,6 +1713,14 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-tsconfig@5.0.0-beta.5: resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} engines: {node: '>=20.20.0'} @@ -1408,6 +1750,10 @@ packages: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -1419,15 +1765,31 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + hono@4.13.3: + resolution: {integrity: sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==} + engines: {node: '>=16.9.0'} + hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -1458,6 +1820,14 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ip-address@10.5.0: + resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1474,6 +1844,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} @@ -1504,6 +1877,9 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jose@6.2.10: + resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -1518,6 +1894,12 @@ packages: json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -1646,6 +2028,18 @@ packages: engines: {node: '>= 16'} hasBin: true + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1654,6 +2048,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} @@ -1677,6 +2079,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -1698,10 +2104,18 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -1763,6 +2177,10 @@ packages: parse5@6.0.1: resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1800,6 +2218,10 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + postcss@8.5.26: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} @@ -1822,11 +2244,19 @@ packages: resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + publint@0.3.23: resolution: {integrity: sha512-5MQipUPcB7MWw84zLUkHrg/H/UBtk3LL+A0GngTTBSsiNJLQurMUaSIRG3edlOrRz4UFe0AOKK9TZdIWviV+jQ==} engines: {node: '>=18'} hasBin: true + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -1836,6 +2266,14 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} @@ -1851,6 +2289,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -1858,6 +2300,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + retry-request@8.0.4: resolution: {integrity: sha512-pI6/7eabUYkZxamkOq0g0uMxKLLGnjzhefY+vL8bVXag5rto4OU2YBTPytWLuHH7aEKD6fn7kJycQfid0Mwnkw==} engines: {node: '>=18'} @@ -1894,6 +2340,10 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1907,6 +2357,10 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -1915,6 +2369,17 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1923,6 +2388,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1951,6 +2432,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} @@ -2032,6 +2517,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + token-types@6.1.2: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} @@ -2077,6 +2566,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript@5.6.1-rc: resolution: {integrity: sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==} engines: {node: '>=14.17'} @@ -2109,6 +2602,10 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2116,6 +2613,10 @@ packages: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + verkit@0.3.2: resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} engines: {node: '>=18.12.0'} @@ -2258,6 +2759,14 @@ packages: yuku-parser@0.8.4: resolution: {integrity: sha512-sw41wouvT5rUmLIp87hmvm5vtF+MRSI3x6yjq6xqpYmtkQj+Ht6N7xRQ8lMhLv8N7JAzughGj0Rfi0jQRSu9HQ==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@andrewbranch/untar.js@1.0.4': {} @@ -2709,6 +3218,11 @@ snapshots: protobufjs: 7.6.5 yargs: 17.7.3 + '@hono/node-server@2.1.1(hono@4.13.3)': + dependencies: + hono: 4.13.3 + optional: true + '@inquirer/external-editor@1.0.3(@types/node@24.13.3)': dependencies: chardet: 2.2.0 @@ -2758,6 +3272,29 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.4.3)': + dependencies: + '@hono/node-server': 2.1.1(hono@4.13.3) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.2.1(supports-color@7.2.0) + express-rate-limit: 8.6.2(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0) + hono: 4.13.3 + jose: 6.2.10 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + optional: true + '@nestjs/common@12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0)': dependencies: file-type: 21.3.4(supports-color@7.2.0) @@ -2790,6 +3327,58 @@ snapshots: '@nestjs/core': 12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 + '@nestm/storage@0.1.0-alpha.9(@azure/core-auth@1.11.0(supports-color@7.2.0))(@nestjs/common@12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(reflect-metadata@0.2.2)(rxjs@7.8.2))(google-auth-library@10.5.0(supports-color@7.2.0))(hono@4.13.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0)(zod@4.4.3)': + dependencies: + files-sdk: 2.2.3(@azure/core-auth@1.11.0(supports-color@7.2.0))(@nestjs/common@12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(google-auth-library@10.5.0(supports-color@7.2.0))(hono@4.13.3)(supports-color@7.2.0)(zod@4.4.3) + optionalDependencies: + '@nestjs/common': 12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + '@nestjs/core': 12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + zod: 4.4.3 + transitivePeerDependencies: + - '@anthropic-ai/claude-agent-sdk' + - '@azure/core-auth' + - '@azure/identity' + - '@azure/storage-blob' + - '@bunny.net/storage-sdk' + - '@cfworker/json-schema' + - '@google-cloud/storage' + - '@googleapis/drive' + - '@microsoft/microsoft-graph-client' + - '@netlify/blobs' + - '@openai/agents' + - '@opentelemetry/api' + - '@supabase/storage-js' + - '@sveltejs/kit' + - '@tanstack/react-start' + - '@vercel/blob' + - astro + - basic-ftp + - box-typescript-sdk-gen + - cloudinary + - convex + - disk + - dropbox + - fastify + - firebase-admin + - google-auth-library + - h3 + - hono + - koa + - next + - node-appwrite + - openai + - pocketbase + - react + - react-dom + - ssh2-sftp-client + - supports-color + - svelte + - uploadthing + - vue + - webdav + '@nestm/tenant@0.1.0-alpha.5(@nestjs/common@12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/testing@12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(reflect-metadata@0.2.2)(rxjs@7.8.2)))(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) @@ -3225,8 +3814,27 @@ snapshots: '@yuku-toolchain/types@0.8.4': {} + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + optional: true + agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + optional: true + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + optional: true + ansi-colors@4.1.3: {} ansi-escapes@7.3.0: @@ -3263,6 +3871,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + aws4fetch@1.0.20: {} + balanced-match@1.0.2: {} base64-js@1.5.1: {} @@ -3273,6 +3883,21 @@ snapshots: bignumber.js@9.3.1: {} + body-parser@2.3.0(supports-color@7.2.0): + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3(supports-color@7.2.0) + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + optional: true + bowser@2.14.1: {} brace-expansion@2.1.4: @@ -3285,8 +3910,23 @@ snapshots: buffer-equal-constant-time@1.0.1: {} + bytes@3.1.2: + optional: true + cac@7.0.0: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + optional: true + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + optional: true + chai@6.2.2: {} chalk@4.1.2: @@ -3339,10 +3979,33 @@ snapshots: commander@10.0.1: {} + commander@15.0.0: {} + consola@3.4.2: {} + content-disposition@1.1.0: + optional: true + + content-type@1.0.5: + optional: true + + content-type@2.1.0: + optional: true + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: + optional: true + + cookie@0.7.2: + optional: true + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + optional: true + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3359,6 +4022,9 @@ snapshots: defu@6.1.7: {} + depd@2.0.0: + optional: true + detect-indent@6.1.0: {} detect-libc@2.1.2: {} @@ -3369,6 +4035,13 @@ snapshots: dts-resolver@3.0.0: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + optional: true + duplexify@4.1.3: dependencies: end-of-stream: 1.4.5 @@ -3382,6 +4055,9 @@ snapshots: dependencies: safe-buffer: 5.2.1 + ee-first@1.1.1: + optional: true + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -3390,6 +4066,9 @@ snapshots: empathic@2.0.1: {} + encodeurl@2.0.0: + optional: true + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -3401,22 +4080,93 @@ snapshots: environment@1.1.0: {} + es-define-property@1.0.1: + optional: true + + es-errors@1.3.0: + optional: true + es-module-lexer@2.3.1: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + optional: true + escalade@3.2.0: {} + escape-html@1.0.3: + optional: true + esprima@4.0.1: {} estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 + etag@1.8.1: + optional: true + + eventsource-parser@3.1.1: + optional: true + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + optional: true + expect-type@1.4.0: {} + express-rate-limit@8.6.2(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + express: 5.2.1(supports-color@7.2.0) + ip-address: 10.5.0 + transitivePeerDependencies: + - supports-color + optional: true + + express@5.2.1(supports-color@7.2.0): + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0(supports-color@7.2.0) + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@7.2.0) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1(supports-color@7.2.0) + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0(supports-color@7.2.0) + send: 1.2.1(supports-color@7.2.0) + serve-static: 2.2.1(supports-color@7.2.0) + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + optional: true + extend@3.0.2: {} extendable-error@0.1.7: {} + fast-deep-equal@3.1.3: + optional: true + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3427,6 +4177,9 @@ snapshots: fast-safe-stringify@2.1.1: {} + fast-uri@3.1.5: + optional: true + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -3451,10 +4204,39 @@ snapshots: transitivePeerDependencies: - supports-color + files-sdk@2.2.3(@azure/core-auth@1.11.0(supports-color@7.2.0))(@nestjs/common@12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(google-auth-library@10.5.0(supports-color@7.2.0))(hono@4.13.3)(supports-color@7.2.0)(zod@4.4.3): + dependencies: + aws4fetch: 1.0.20 + commander: 15.0.0 + picomatch: 4.0.5 + safe-regex2: 5.1.1 + optionalDependencies: + '@azure/core-auth': 1.11.0(supports-color@7.2.0) + '@modelcontextprotocol/sdk': 1.30.0(supports-color@7.2.0)(zod@4.4.3) + '@nestjs/common': 12.0.0-alpha.5(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + google-auth-library: 10.5.0(supports-color@7.2.0) + hono: 4.13.3 + zod: 4.4.3 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + optional: true + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -3469,6 +4251,12 @@ snapshots: dependencies: fetch-blob: 3.2.0 + forwarded@0.2.0: + optional: true + + fresh@2.0.0: + optional: true + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -3484,6 +4272,9 @@ snapshots: fsevents@2.3.3: optional: true + function-bind@1.1.2: + optional: true + gaxios@7.1.3(supports-color@7.2.0): dependencies: extend: 3.0.2 @@ -3511,6 +4302,26 @@ snapshots: get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + optional: true + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + optional: true + get-tsconfig@5.0.0-beta.5: dependencies: resolve-pkg-maps: 1.0.0 @@ -3567,6 +4378,9 @@ snapshots: google-logging-utils@1.1.3: {} + gopd@1.2.0: + optional: true + graceful-fs@4.2.11: {} gtoken@8.0.0(supports-color@7.2.0): @@ -3578,12 +4392,32 @@ snapshots: has-flag@4.0.0: {} + has-symbols@1.1.0: + optional: true + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + optional: true + highlight.js@10.7.3: {} + hono@4.13.3: + optional: true + hookable@6.1.1: {} html-escaper@2.0.2: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + optional: true + http-proxy-agent@7.0.2(supports-color@7.2.0): dependencies: agent-base: 7.1.4 @@ -3612,6 +4446,12 @@ snapshots: inherits@2.0.4: {} + ip-address@10.5.0: + optional: true + + ipaddr.js@1.9.1: + optional: true + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -3622,6 +4462,9 @@ snapshots: is-number@7.0.0: {} + is-promise@4.0.0: + optional: true + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 @@ -3651,6 +4494,9 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jose@6.2.10: + optional: true + js-tokens@10.0.0: {} js-yaml@3.15.1: @@ -3666,6 +4512,12 @@ snapshots: dependencies: bignumber.js: 9.3.1 + json-schema-traverse@1.0.0: + optional: true + + json-schema-typed@8.0.2: + optional: true + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -3773,6 +4625,15 @@ snapshots: marked@9.1.6: {} + math-intrinsics@1.1.0: + optional: true + + media-typer@1.1.1: + optional: true + + merge-descriptors@2.0.0: + optional: true + merge2@1.4.1: {} micromatch@4.0.8: @@ -3780,6 +4641,14 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.54.0: + optional: true + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + optional: true + minimatch@9.0.9: dependencies: brace-expansion: 2.1.4 @@ -3798,6 +4667,11 @@ snapshots: nanoid@3.3.18: {} + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + optional: true + node-domexception@1.0.0: {} node-emoji@2.2.0: @@ -3817,8 +4691,16 @@ snapshots: object-hash@3.0.0: {} + object-inspect@1.13.4: + optional: true + obug@2.1.4: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + optional: true + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -3889,6 +4771,9 @@ snapshots: parse5@6.0.1: {} + parseurl@1.3.3: + optional: true + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -3912,6 +4797,9 @@ snapshots: pify@4.0.1: {} + pkce-challenge@5.0.1: + optional: true + postcss@8.5.26: dependencies: nanoid: 3.3.18 @@ -3940,6 +4828,12 @@ snapshots: '@types/node': 24.13.3 long: 5.3.2 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + optional: true + publint@0.3.23: dependencies: '@publint/pack': 0.1.6 @@ -3947,12 +4841,29 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + optional: true + quansync@0.2.11: {} quansync@1.0.0: {} queue-microtask@1.2.3: {} + range-parser@1.3.0: + optional: true + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + optional: true + read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 @@ -3970,10 +4881,15 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: + optional: true + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} + ret@0.5.0: {} + retry-request@8.0.4(supports-color@7.2.0): dependencies: extend: 3.0.2 @@ -4021,6 +4937,17 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.3 '@rolldown/binding-win32-x64-msvc': 1.2.3 + router@2.2.0(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + optional: true + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -4035,16 +4962,82 @@ snapshots: safe-buffer@5.2.1: {} + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + safer-buffer@2.1.2: {} semver@7.8.5: {} + send@1.2.1(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + 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.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + optional: true + + serve-static@2.2.1(supports-color@7.2.0): + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + optional: true + + setprototypeof@1.2.0: + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + optional: true + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + optional: true + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + optional: true + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + optional: true + siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -4066,6 +5059,9 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: + optional: true + std-env@4.2.0: {} stream-events@1.0.5: @@ -4149,6 +5145,9 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: + optional: true + token-types@6.1.2: dependencies: '@borewit/text-codec': 0.2.2 @@ -4186,6 +5185,13 @@ snapshots: tslib@2.8.1: {} + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + optional: true + typescript@5.6.1-rc: {} typescript@7.0.2: @@ -4228,10 +5234,16 @@ snapshots: universalify@0.1.2: {} + unpipe@1.0.0: + optional: true + util-deprecate@1.0.2: {} validate-npm-package-name@5.0.1: {} + vary@1.1.2: + optional: true + verkit@0.3.2: {} vite@8.2.1(@types/node@24.13.3): @@ -4362,3 +5374,11 @@ snapshots: '@yuku-parser/binding-linux-x64-musl': 0.8.4 '@yuku-parser/binding-win32-arm64': 0.8.4 '@yuku-parser/binding-win32-x64': 0.8.4 + + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + optional: true + + zod@4.4.3: + optional: true diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index 3cbd306..74cd589 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -8,6 +8,7 @@ const expectedExports = [ ".", "./core", "./files", + "./storage-workspace", "./fields", "./tenant", "./http", @@ -45,6 +46,7 @@ if (files.some((file) => file.endsWith(".map"))) { const optionalPeers = [ "@nestjs/common", "@nestjs/core", + "@nestm/storage", "@nestm/tenant", "@aws-sdk/client-kms", "@google-cloud/kms", @@ -53,16 +55,37 @@ const optionalPeers = [ "class-transformer", ]; for (const peer of optionalPeers) { + if (manifest.peerDependencies?.[peer] === undefined) { + throw new Error(`Missing integration peer dependency: ${peer}`); + } if (manifest.peerDependenciesMeta?.[peer]?.optional !== true) { throw new Error(`Integration peer must remain optional: ${peer}`); } } +if (manifest.devDependencies?.["@nestm/storage"] !== "0.1.0-alpha.9") { + throw new Error("The storage bridge must test against exactly @nestm/storage@0.1.0-alpha.9"); +} const coreSource = readFileSync(join(root, "dist/core/index.mjs"), "utf8"); for (const peer of optionalPeers) { if (coreSource.includes(peer)) { throw new Error(`The framework-neutral core unexpectedly imports optional peer ${peer}`); } } +const rootSource = readFileSync(join(root, "dist/index.mjs"), "utf8"); +if (rootSource.includes("@nestm/storage")) { + throw new Error("The root entry point unexpectedly imports optional peer @nestm/storage"); +} +for (const bridgeArtifact of [ + "dist/storage-workspace/index.mjs", + "dist/storage-workspace/index.d.mts", +]) { + const source = readFileSync(join(root, bridgeArtifact), "utf8"); + if (/from\s+["']@nestm\/storage["']/u.test(source)) { + throw new Error( + `The storage bridge must use framework-neutral @nestm/storage subpaths: ${bridgeArtifact}`, + ); + } +} const core = await import(pathToFileURL(join(root, "dist/core/index.mjs")).href); if (Object.keys(core).length === 0) throw new Error("The core entry point exports no values"); diff --git a/scripts/test-packed-consumers.mjs b/scripts/test-packed-consumers.mjs index bdade94..40c5fa9 100644 --- a/scripts/test-packed-consumers.mjs +++ b/scripts/test-packed-consumers.mjs @@ -23,6 +23,7 @@ try { const tarball = join(packageDirectory, tarballName); testCoreConsumer(tarball); + testStorageWorkspaceConsumer(tarball); testIntegrationConsumer(tarball); verifyTarball(tarball); } finally { @@ -96,13 +97,143 @@ await engine.close(); run(join(directory, "node_modules/.bin/tsc"), ["-p", "tsconfig.json"], directory); run(process.execPath, ["dist/consumer.js"], directory); - for (const absent of ["@nestjs", "@nestm/tenant", "@aws-sdk", "@google-cloud", "@azure"]) { + for (const absent of [ + "@nestjs", + "@nestm/storage", + "@nestm/tenant", + "@aws-sdk", + "@google-cloud", + "@azure", + ]) { if (existsSync(join(directory, "node_modules", ...absent.split("/")))) { throw new Error(`Core-only consumer unexpectedly installed ${absent}`); } } } +function testStorageWorkspaceConsumer(tarball) { + const directory = join(temporaryRoot, "storage-workspace-consumer"); + mkdirSync(directory); + writeJson(join(directory, "package.json"), { + name: "@nestm/crypto-storage-workspace-consumer", + private: true, + type: "module", + }); + run( + "npm", + [ + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--legacy-peer-deps", + tarball, + "@nestm/storage@0.1.0-alpha.9", + "typescript@7.0.2", + "@types/node@24.13.3", + ], + directory, + ); + writeFileSync( + join(directory, "consumer.ts"), + `import assert from "node:assert/strict"; +import { generateKeySync } from "node:crypto"; +import { StorageClient } from "@nestm/storage/core"; +import { createMemoryStorageDriver } from "@nestm/storage/testing"; +import { mountStorageWorkspace } from "@nestm/storage/workspace"; +import { AesKeyRingProvider, CipherEngine } from "@nestm/crypto/core"; +import { + createCipherEngineWorkspaceCipher, + protectStorageWorkspace, +} from "@nestm/crypto/storage-workspace"; + +const client = new StorageClient("packed-protected-memory", createMemoryStorageDriver()); +const rawWorkspace = mountStorageWorkspace(client, { + prefix: "validation-only/protected", + permissions: ["list", "read", "search", "write", "create", "replace", "copy", "move", "delete"], + limits: { + maxReadBytes: 64 * 1024, + maxWriteBytes: 64 * 1024, + maxPageSize: 20, + maxSearchResults: 20, + maxSearchScan: 100, + }, +}); +const engine = new CipherEngine({ + defaultProvider: "packed-storage", + providers: [{ + name: "packed-storage", + provider: new AesKeyRingProvider({ + activeKeyId: "packed-storage-key", + keys: { "packed-storage-key": generateKeySync("aes", { length: 256 }) }, + }), + }], + maxPayloadBytes: 64 * 1024, +}); +const cipher = createCipherEngineWorkspaceCipher(engine, { + allowedProviders: ["packed-storage"], +}); +const logicalLimits = { + ...rawWorkspace.limits, + maxReadBytes: 8 * 1024, + maxWriteBytes: 8 * 1024, +}; +const workspace = protectStorageWorkspace({ + storage: rawWorkspace, + cipher, + scopeContext: "organization:packed/workspace:protected", + policyRevision: "packed-v1", + limits: logicalLimits, + maxCiphertextBytes: 64 * 1024, + pathSearch: "provider-visible", +}); + +assert.equal(workspace.protection.outerFormat, "nestm-protected-storage-workspace"); +assert.equal(workspace.protection.outerVersion, 1); +assert.equal(workspace.protection.body, "encrypted"); +assert.equal(workspace.protection.metadata, "encrypted"); +assert.equal(workspace.protection.pathSearch, "provider-visible"); +assert.equal(workspace.protection.policyRevision, "packed-v1"); + +const plaintext = "packed protected workspace secret"; +await workspace.writeFile("reports/result.txt", plaintext, { + // The Files SDK memory adapter deliberately lacks atomic create-if-absent. + mode: "overwrite", + contentType: "text/plain; charset=utf-8", +}); +const raw = await rawWorkspace.readText("reports/result.txt"); +assert.equal(raw.text.includes(plaintext), false); +const backingRecord: unknown = JSON.parse(raw.text); +assert.ok(typeof backingRecord === "object" && backingRecord !== null); +const record = backingRecord as Record; +assert.deepEqual(Object.keys(record), ["v", "record", "version", "metadata", "content"]); +assert.equal(record.v, 1); +const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +assert.match(String(record.record), uuidV4); +assert.match(String(record.version), uuidV4); +assert.match(String(record.metadata), /^nmc1\\./u); +assert.match(String(record.content), /^nmc1\\./u); +for (const envelope of [String(record.metadata), String(record.content)]) { + for (const segment of envelope.split(".").slice(1)) { + assert.equal(Buffer.from(segment, "base64url").includes(Buffer.from(plaintext)), false); + } +} + +const restored = await workspace.readText("reports/result.txt"); +assert.equal(restored.text, plaintext); +assert.equal(restored.contentType, "text/plain; charset=utf-8"); +const page = await workspace.list({ directory: "reports" }); +assert.equal(page.entries[0]?.path, "reports/result.txt"); + +await engine.close(); +await client.onApplicationShutdown(); +`, + ); + writeTypeScriptConfig(directory); + run(join(directory, "node_modules/.bin/tsc"), ["-p", "tsconfig.json"], directory); + run(process.execPath, ["dist/consumer.js"], directory); +} + function testIntegrationConsumer(tarball) { const directory = join(temporaryRoot, "integration-consumer"); mkdirSync(directory); diff --git a/src/storage-workspace/cipher-engine-adapter.ts b/src/storage-workspace/cipher-engine-adapter.ts new file mode 100644 index 0000000..c50a5f8 --- /dev/null +++ b/src/storage-workspace/cipher-engine-adapter.ts @@ -0,0 +1,100 @@ +import type { CipherEngine } from "../core/cipher-engine.js"; + +import type { + AuthenticatedWorkspaceCipher, + AuthenticatedWorkspaceCipherContext, + CipherEngineWorkspaceCipherOptions, +} from "./types.js"; + +/** Adapt a CipherEngine without transferring ownership of its lifecycle. */ +export function createCipherEngineWorkspaceCipher( + engine: CipherEngine, + options: CipherEngineWorkspaceCipherOptions = {}, +): AuthenticatedWorkspaceCipher { + if ( + typeof engine !== "object" || + engine === null || + typeof engine.encryptBytes !== "function" || + typeof engine.decryptBytes !== "function" || + typeof engine.defaultProvider !== "string" + ) { + throw new TypeError("A CipherEngine is required."); + } + if (typeof options !== "object" || options === null) { + throw new TypeError("CipherEngine workspace-cipher options are invalid."); + } + const provider = captureOptionalIdentifier(options.provider, "provider"); + const cipher = captureOptionalIdentifier(options.cipher, "cipher"); + const allowedProviders = captureAllowedProviders( + options.allowedProviders, + provider ?? engine.defaultProvider, + ); + const writeProvider = provider ?? engine.defaultProvider; + if (!allowedProviders.includes(writeProvider)) { + throw new TypeError("The CipherEngine write provider must be admitted for reads."); + } + for (const allowedProvider of allowedProviders) { + if (!engine.hasProvider(allowedProvider)) { + throw new TypeError("A CipherEngine allowed provider is not registered."); + } + } + + return Object.freeze({ + async decryptBytes( + envelope: string, + context: AuthenticatedWorkspaceCipherContext, + ): Promise { + return await engine.decryptBytes(envelope, { + aad: context.authenticatedData, + allowedProviders, + keyContext: context.keyContext, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + }, + async encryptBytes( + plaintext: Uint8Array, + context: AuthenticatedWorkspaceCipherContext, + ): Promise { + return await engine.encryptBytes(plaintext, { + aad: context.authenticatedData, + keyContext: context.keyContext, + ...(cipher === undefined ? {} : { cipher }), + ...(provider === undefined ? {} : { provider }), + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + }, + } satisfies AuthenticatedWorkspaceCipher); +} + +function captureOptionalIdentifier(value: string | undefined, label: string): string | undefined { + if (value === undefined) return undefined; + if ( + typeof value !== "string" || + value.length === 0 || + value.length > 128 || + value.trim() !== value || + /\p{C}/u.test(value) + ) { + throw new TypeError(`The CipherEngine ${label} selection is invalid.`); + } + return value; +} + +function captureAllowedProviders( + values: readonly string[] | undefined, + fallback: string, +): readonly string[] { + const candidates = values === undefined ? [fallback] : [...values]; + if (candidates.length === 0) { + throw new TypeError("At least one allowed CipherEngine provider is required."); + } + const unique = new Set(); + for (const candidate of candidates) { + const captured = captureOptionalIdentifier(candidate, "allowed provider"); + if (captured === undefined || unique.has(captured)) { + throw new TypeError("CipherEngine allowed providers are invalid."); + } + unique.add(captured); + } + return Object.freeze([...unique]); +} diff --git a/src/storage-workspace/index.ts b/src/storage-workspace/index.ts new file mode 100644 index 0000000..67ebc13 --- /dev/null +++ b/src/storage-workspace/index.ts @@ -0,0 +1,13 @@ +export { createCipherEngineWorkspaceCipher } from "./cipher-engine-adapter.js"; +export { protectStorageWorkspace } from "./protected-storage-workspace.js"; +export type { + AuthenticatedWorkspaceCipher, + AuthenticatedWorkspaceCipherContext, + CipherEngineWorkspaceCipherOptions, + ProtectedStorageOperationOptions, + ProtectedStorageWorkspace, + ProtectedStorageWorkspaceByteFile, + ProtectedStorageWorkspacePathSearch, + ProtectedStorageWorkspaceProtection, + ProtectStorageWorkspaceOptions, +} from "./types.js"; diff --git a/src/storage-workspace/protected-storage-workspace.spec.ts b/src/storage-workspace/protected-storage-workspace.spec.ts new file mode 100644 index 0000000..468e5f5 --- /dev/null +++ b/src/storage-workspace/protected-storage-workspace.spec.ts @@ -0,0 +1,801 @@ +import { randomUUID } from "node:crypto"; +import { getEventListeners } from "node:events"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { StorageClient, StorageError, StorageErrorCode } from "@nestm/storage/core"; +import { createFsStorageDriver } from "@nestm/storage/files-sdk/fs"; +import { + Aes256GcmStorageWorkspaceCursorCodec, + mountStorageWorkspace, + type StorageWorkspace, + type StorageWorkspaceLimits, + type StorageWorkspacePermission, +} from "@nestm/storage/workspace"; + +import { AesKeyRingProvider, CipherEngine } from "../core/index.js"; +import { createCipherEngineWorkspaceCipher } from "./cipher-engine-adapter.js"; +import { protectStorageWorkspace } from "./protected-storage-workspace.js"; +import type { + AuthenticatedWorkspaceCipher, + ProtectedStorageWorkspace, + ProtectStorageWorkspaceOptions, +} from "./types.js"; + +const PREFIX = "protected-workspace-fixture"; +const MAX_CIPHERTEXT_BYTES = 32 * 1024; +const BACKING_LIMITS: Readonly = Object.freeze({ + cursorTtlMs: 60_000, + maxCursorBytes: 4096, + maxPageSize: 20, + maxPathBytes: 512, + maxReadBytes: 64 * 1024, + maxSearchResults: 20, + maxSearchScan: 100, + maxWriteBytes: 64 * 1024, +}); +const LOGICAL_LIMITS: Readonly = Object.freeze({ + ...BACKING_LIMITS, + maxReadBytes: 4096, + maxWriteBytes: 4096, +}); +const ALL_PERMISSIONS = Object.freeze([ + "list", + "read", + "search", + "write", + "create", + "replace", + "copy", + "move", + "delete", +] satisfies readonly StorageWorkspacePermission[]); +const KEY = new Uint8Array(32).fill(0x41); +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +interface RawOuter { + readonly v: 1; + readonly record: string; + readonly version: string; + readonly metadata: string; + readonly content: string; +} + +interface Fixture { + readonly client: StorageClient; + readonly engine: CipherEngine; + readonly cipher: AuthenticatedWorkspaceCipher; + readonly storage: StorageWorkspace; + readonly workspace: ProtectedStorageWorkspace; +} + +function engine(): CipherEngine { + return new CipherEngine({ + defaultProvider: "local", + maxPayloadBytes: LOGICAL_LIMITS.maxWriteBytes, + providers: [ + { + name: "local", + provider: new AesKeyRingProvider({ + activeKeyId: "test-key-v1", + keys: { "test-key-v1": KEY }, + }), + }, + ], + }); +} + +function backing( + client: StorageClient, + prefix = PREFIX, + permissions: readonly StorageWorkspacePermission[] = ALL_PERMISSIONS, +): StorageWorkspace { + return mountStorageWorkspace(client, { + cursor: { + codec: new Aes256GcmStorageWorkspaceCursorCodec({ + activeKeyId: "cursor-v1", + keys: { "cursor-v1": new Uint8Array(32).fill(0x43) }, + }), + mountId: `test/${prefix}`, + scope: "protected-workspace-tests", + }, + limits: BACKING_LIMITS, + permissions, + prefix, + }); +} + +function protectedView( + storage: StorageWorkspace, + cipher: AuthenticatedWorkspaceCipher, + overrides: Partial< + Pick< + ProtectStorageWorkspaceOptions, + "limits" | "maxCiphertextBytes" | "pathSearch" | "policyRevision" | "scopeContext" | "signal" + > + > = {}, +): ProtectedStorageWorkspace { + return protectStorageWorkspace({ + cipher, + limits: overrides.limits ?? LOGICAL_LIMITS, + maxCiphertextBytes: overrides.maxCiphertextBytes ?? MAX_CIPHERTEXT_BYTES, + pathSearch: overrides.pathSearch ?? "disabled", + policyRevision: overrides.policyRevision ?? "policy-v1", + scopeContext: overrides.scopeContext ?? "tenant/acme/workspace/primary", + ...(overrides.signal === undefined ? {} : { signal: overrides.signal }), + storage, + }); +} + +function fixture(): Fixture { + const root = mkdtempSync(join(tmpdir(), "nestm-protected-workspace-")); + temporaryRoots.push(root); + const client = new StorageClient( + "protected-workspace-test", + createFsStorageDriver({ adapter: { root } }), + ); + const testEngine = engine(); + const cipher = createCipherEngineWorkspaceCipher(testEngine); + const storage = backing(client); + return { + cipher, + client, + engine: testEngine, + storage, + workspace: protectedView(storage, cipher), + }; +} + +async function rawText(client: StorageClient, path: string, prefix = PREFIX): Promise { + return await client.downloadText(`${prefix}/${path}`, { + maxBytes: BACKING_LIMITS.maxReadBytes, + }); +} + +async function putRaw( + client: StorageClient, + path: string, + body: string, + prefix = PREFIX, +): Promise { + await client.upload(`${prefix}/${path}`, body, { + contentType: "application/octet-stream", + }); +} + +function parseRaw(value: string): RawOuter { + const parsed: unknown = JSON.parse(value); + if ( + typeof parsed !== "object" || + parsed === null || + !("v" in parsed) || + parsed.v !== 1 || + !("record" in parsed) || + typeof parsed.record !== "string" || + !("version" in parsed) || + typeof parsed.version !== "string" || + !("metadata" in parsed) || + typeof parsed.metadata !== "string" || + !("content" in parsed) || + typeof parsed.content !== "string" + ) { + throw new TypeError("The raw test object is malformed."); + } + return { + v: 1, + record: parsed.record, + version: parsed.version, + metadata: parsed.metadata, + content: parsed.content, + }; +} + +function mutateEnvelope(value: string): string { + const last = value.at(-1); + if (last === undefined) throw new Error("Missing envelope value."); + return `${value.slice(0, -1)}${last === "A" ? "B" : "A"}`; +} + +describe("protected StorageWorkspace", () => { + it("round-trips text and bytes while exposing only a frozen protected view", async () => { + const { client, workspace } = fixture(); + const path = "reports/private.md"; + const plaintext = "classified workspace artifact"; + + const created = await workspace.writeFile(path, plaintext, { + contentType: "text/markdown; charset=utf-8", + mode: "create", + }); + + expect(created).toMatchObject({ + contentType: "text/markdown; charset=utf-8", + kind: "file", + name: "private.md", + path, + size: new TextEncoder().encode(plaintext).byteLength, + }); + await expect(workspace.readText(path)).resolves.toMatchObject({ text: plaintext }); + await expect(workspace.readBytes(path)).resolves.toMatchObject({ + bytes: new TextEncoder().encode(plaintext), + }); + + const binary = new Uint8Array([0, 1, 2, 254, 255]); + await workspace.writeFile("payload.bin", binary, { + contentType: "application/x-fixture", + mode: "create", + }); + await expect(workspace.readBytes("payload.bin")).resolves.toMatchObject({ bytes: binary }); + await expect(workspace.readText("payload.bin")).rejects.toMatchObject({ + code: StorageErrorCode.INVALID_ARGUMENT, + }); + + const raw = await rawText(client, path); + const outer = parseRaw(raw); + expect(Object.keys(outer)).toEqual(["v", "record", "version", "metadata", "content"]); + expect(outer).toMatchObject({ v: 1 }); + expect(outer.metadata).toMatch(/^nmc1\./u); + expect(outer.content).toMatch(/^nmc1\./u); + expect(raw).not.toContain(plaintext); + expect(raw).not.toContain(path); + expect(raw).not.toContain("text/markdown"); + await expect(client.head(`${PREFIX}/${path}`)).resolves.toMatchObject({ + contentType: "application/octet-stream", + }); + + expect(workspace.protection).toEqual({ + body: "encrypted", + cipherEnvelope: "nmc1", + kind: "authenticated-encryption", + metadata: "encrypted", + outerFormat: "nestm-protected-storage-workspace", + outerVersion: 1, + pathBinding: "scope-policy-path-record-version-purpose", + pathSearch: "disabled", + policyRevision: "policy-v1", + }); + expect(Object.isFrozen(workspace)).toBe(true); + expect(Object.isFrozen(workspace.limits)).toBe(true); + expect(Object.isFrozen(workspace.protection)).toBe(true); + expect(Reflect.ownKeys(workspace)).toEqual(["limits", "protection"]); + expect(workspace.allows("copy")).toBe(false); + expect(workspace.allows("move")).toBe(false); + expect(workspace.allows("search")).toBe(false); + expect(workspace.permissions.has("copy")).toBe(false); + expect(Reflect.get(workspace, "storage")).toBeUndefined(); + expect(Reflect.get(workspace, "cipher")).toBeUndefined(); + }); + + it("rejects plaintext, noncanonical outer records, and malformed nmc1-only writes", async () => { + const { cipher, client, storage, workspace } = fixture(); + await putRaw(client, "plaintext.txt", "provider plaintext"); + await expect(workspace.readText("plaintext.txt")).rejects.toMatchObject({ + cause: undefined, + code: StorageErrorCode.PROVIDER, + key: "plaintext.txt", + message: "Protected workspace read failed.", + }); + + await workspace.writeFile("canonical.txt", "protected", { mode: "create" }); + const outer = parseRaw(await rawText(client, "canonical.txt")); + await putRaw( + client, + "reordered.txt", + JSON.stringify({ + record: outer.record, + v: outer.v, + version: outer.version, + metadata: outer.metadata, + content: outer.content, + }), + ); + await expect(workspace.stat("reordered.txt")).rejects.toMatchObject({ + code: StorageErrorCode.PROVIDER, + }); + + const invalidCipher: AuthenticatedWorkspaceCipher = Object.freeze({ + decryptBytes: async () => new Uint8Array(), + encryptBytes: async () => "not-an-nmc1-envelope", + }); + const invalid = protectedView(storage, invalidCipher, { scopeContext: "invalid-cipher" }); + await expect( + invalid.writeFile("invalid.txt", "secret", { mode: "create" }), + ).rejects.toMatchObject({ + code: StorageErrorCode.PROVIDER, + }); + await expect(client.exists(`${PREFIX}/invalid.txt`)).resolves.toBe(false); + + const readAgain = protectedView(storage, cipher); + await expect(readAgain.readText("canonical.txt")).resolves.toMatchObject({ text: "protected" }); + }); + + it("binds ciphertext to scope, policy, full mounted path, record, version, and purpose", async () => { + const { cipher, client, storage, workspace } = fixture(); + await workspace.writeFile("source.txt", "bound content", { mode: "create" }); + const sourceRaw = await rawText(client, "source.txt"); + + await putRaw(client, "replayed.txt", sourceRaw); + await expect(workspace.readText("replayed.txt")).rejects.toMatchObject({ + code: StorageErrorCode.PROVIDER, + }); + + const otherScope = protectedView(storage, cipher, { scopeContext: "tenant/other" }); + await expect(otherScope.readText("source.txt")).rejects.toMatchObject({ + code: StorageErrorCode.PROVIDER, + }); + const otherPolicy = protectedView(storage, cipher, { policyRevision: "policy-v2" }); + await expect(otherPolicy.readText("source.txt")).rejects.toMatchObject({ + code: StorageErrorCode.PROVIDER, + }); + + const versionReplay = { ...parseRaw(sourceRaw), version: randomUUID() }; + await putRaw(client, "source.txt", JSON.stringify(versionReplay)); + await expect(workspace.stat("source.txt")).rejects.toMatchObject({ + code: StorageErrorCode.PROVIDER, + }); + + const recordReplay = { ...parseRaw(sourceRaw), record: randomUUID() }; + await putRaw(client, "source.txt", JSON.stringify(recordReplay)); + await expect(workspace.stat("source.txt")).rejects.toMatchObject({ + code: StorageErrorCode.PROVIDER, + }); + + const purposeReplay = parseRaw(sourceRaw); + await putRaw( + client, + "source.txt", + JSON.stringify({ + ...purposeReplay, + metadata: purposeReplay.content, + content: purposeReplay.metadata, + }), + ); + await expect(workspace.stat("source.txt")).rejects.toMatchObject({ + code: StorageErrorCode.PROVIDER, + }); + + await putRaw(client, "source.txt", sourceRaw); + const child = workspace.mount("nested"); + await child.writeFile("mounted.txt", "same full path", { mode: "create" }); + await expect(workspace.readText("nested/mounted.txt")).resolves.toMatchObject({ + text: "same full path", + }); + }); + + it("authenticates metadata for stat/list and content for reads", async () => { + const { cipher, client, storage, workspace } = fixture(); + await workspace.writeFile("entry.txt", "authenticated body", { + contentType: "text/custom", + mode: "create", + }); + const original = parseRaw(await rawText(client, "entry.txt")); + + await putRaw( + client, + "entry.txt", + JSON.stringify({ ...original, content: mutateEnvelope(original.content) }), + ); + await expect(workspace.stat("entry.txt")).resolves.toMatchObject({ + contentType: "text/custom", + size: 18, + }); + await expect(workspace.list()).resolves.toMatchObject({ + entries: [expect.objectContaining({ contentType: "text/custom", path: "entry.txt" })], + }); + await expect(workspace.readText("entry.txt")).rejects.toMatchObject({ + code: StorageErrorCode.PROVIDER, + }); + + await putRaw( + client, + "entry.txt", + JSON.stringify({ ...original, metadata: mutateEnvelope(original.metadata) }), + ); + await expect(workspace.stat("entry.txt")).rejects.toMatchObject({ + code: StorageErrorCode.PROVIDER, + }); + await expect(workspace.list()).rejects.toMatchObject({ + code: StorageErrorCode.PROVIDER, + }); + + const metadataRewriter: AuthenticatedWorkspaceCipher = { + decryptBytes: cipher.decryptBytes.bind(cipher), + async encryptBytes(plaintext, context): Promise { + const decoded = new TextDecoder().decode(plaintext); + const marker = '"contentType":"text/plain"'; + if (!decoded.startsWith("{") || !decoded.includes(marker)) { + return await cipher.encryptBytes(plaintext, context); + } + const rewritten = new TextEncoder().encode( + decoded.replace(marker, '"contentType":" text/plain"'), + ); + try { + return await cipher.encryptBytes(rewritten, context); + } finally { + rewritten.fill(0); + } + }, + }; + const poisoned = protectedView(storage, metadataRewriter); + await poisoned.writeFile("invalid-content-type.txt", "body", { + contentType: "text/plain", + mode: "create", + }); + await expect(poisoned.stat("invalid-content-type.txt")).rejects.toMatchObject({ + code: StorageErrorCode.PROVIDER, + }); + }); + + it("preserves create, CAS replace, and explicit overwrite semantics", async () => { + const { client, workspace } = fixture(); + const created = await workspace.writeFile("cas.txt", "v1", { mode: "create" }); + const firstOuter = parseRaw(await rawText(client, "cas.txt")); + + await expect( + workspace.writeFile("cas.txt", "duplicate", { mode: "create" }), + ).rejects.toMatchObject({ + code: StorageErrorCode.CONFLICT, + message: "Protected workspace write failed.", + }); + await expect( + workspace.writeFile("cas.txt", "stale", { etag: "stale-etag", mode: "replace" }), + ).rejects.toMatchObject({ code: StorageErrorCode.CONFLICT }); + await expect(workspace.readText("cas.txt")).resolves.toMatchObject({ text: "v1" }); + + const replaced = await workspace.writeFile("cas.txt", "v2", { + etag: created.etag ?? "", + mode: "replace", + }); + const secondOuter = parseRaw(await rawText(client, "cas.txt")); + expect(secondOuter.record).toBe(firstOuter.record); + expect(secondOuter.version).not.toBe(firstOuter.version); + await expect(workspace.readText("cas.txt")).resolves.toMatchObject({ text: "v2" }); + + await workspace.writeFile("cas.txt", "v3", { mode: "overwrite" }); + const thirdOuter = parseRaw(await rawText(client, "cas.txt")); + expect(thirdOuter.record).not.toBe(secondOuter.record); + expect(thirdOuter.version).not.toBe(secondOuter.version); + await expect(workspace.readText("cas.txt")).resolves.toMatchObject({ text: "v3" }); + + await expect( + workspace.writeFile("metadata.txt", "no", { + metadata: { leaked: "plaintext" }, + mode: "create", + }), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + const createWithEtag = { + etag: replaced.etag ?? "etag", + mode: "create" as const, + }; + await expect( + workspace.writeFile("etag-create.txt", "no", createWithEtag), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + const overwriteWithEtag = { + etag: "unexpected", + mode: "overwrite" as const, + }; + await expect( + workspace.writeFile("etag-overwrite.txt", "no", overwriteWithEtag), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + }); + + it("reads retained key revisions and writes new versions with the active key", async () => { + const { client, storage, workspace } = fixture(); + const created = await workspace.writeFile("rotation.txt", "encrypted with v1", { + mode: "create", + }); + const firstOuter = parseRaw(await rawText(client, "rotation.txt")); + + const rotatedEngine = new CipherEngine({ + defaultProvider: "local", + maxPayloadBytes: LOGICAL_LIMITS.maxWriteBytes, + providers: [ + { + name: "local", + provider: new AesKeyRingProvider({ + activeKeyId: "test-key-v2", + keys: { + "test-key-v1": KEY, + "test-key-v2": new Uint8Array(32).fill(0x42), + }, + }), + }, + ], + }); + const rotated = protectedView(storage, createCipherEngineWorkspaceCipher(rotatedEngine)); + await expect(rotated.readText("rotation.txt")).resolves.toMatchObject({ + text: "encrypted with v1", + }); + expect(rotatedEngine.inspect(firstOuter.content).keyReference).toBe("test-key-v1"); + + await rotated.writeFile("rotation.txt", "encrypted with v2", { + etag: created.etag ?? "", + mode: "replace", + }); + await expect(rotated.readText("rotation.txt")).resolves.toMatchObject({ + text: "encrypted with v2", + }); + const secondOuter = parseRaw(await rawText(client, "rotation.txt")); + expect(rotatedEngine.inspect(secondOuter.metadata).keyReference).toBe("test-key-v2"); + expect(rotatedEngine.inspect(secondOuter.content).keyReference).toBe("test-key-v2"); + }); + + it("preserves list/search query fields and makes path disclosure explicit", async () => { + const { cipher, storage, workspace } = fixture(); + await workspace.writeFile("root.txt", "root", { mode: "create" }); + await workspace.writeFile("reports/Quarter.TXT", "quarter", { mode: "create" }); + await workspace.writeFile("reports/second.txt", "second", { mode: "create" }); + + const first = await workspace.list({ directory: "reports", limit: 1, recursive: true }); + expect(first.entries).toHaveLength(1); + expect(first.entries[0]?.path).toMatch(/^reports\//u); + expect(first.cursor).toEqual(expect.any(String)); + const second = await workspace.list({ + cursor: first.cursor, + directory: "reports", + limit: 1, + recursive: true, + }); + expect(second.entries).toHaveLength(1); + expect(second.entries[0]?.path).toMatch(/^reports\//u); + + await expect(workspace.search("Quarter")).rejects.toMatchObject({ + code: StorageErrorCode.NOT_SUPPORTED, + }); + expect(workspace.permissions.has("search")).toBe(false); + + const searchable = protectedView(storage, cipher, { pathSearch: "provider-visible" }); + expect(searchable.protection.pathSearch).toBe("provider-visible"); + expect(searchable.allows("search")).toBe(true); + const results = await searchable.search("quarter", { + caseInsensitive: true, + directory: "reports", + limit: 1, + match: "substring", + }); + expect(results.entries).toEqual([ + expect.objectContaining({ path: "reports/Quarter.TXT", size: 7 }), + ]); + }); + + it("forwards conditional and unconditional delete without exposing copy/move", async () => { + const { workspace } = fixture(); + const conditional = await workspace.writeFile("conditional.txt", "delete me", { + mode: "create", + }); + await workspace.deleteFile("conditional.txt", { etag: conditional.etag ?? "" }); + await expect(workspace.stat("conditional.txt")).rejects.toMatchObject({ + code: StorageErrorCode.NOT_FOUND, + }); + + await workspace.writeFile("unconditional.txt", "delete me too", { mode: "create" }); + await workspace.deleteFile("unconditional.txt", { mode: "unconditional" }); + await expect(workspace.stat("unconditional.txt")).rejects.toMatchObject({ + code: StorageErrorCode.NOT_FOUND, + }); + + await expect( + workspace.copyFile("source.txt", "copy.txt", { mode: "overwrite" }), + ).rejects.toMatchObject({ code: StorageErrorCode.NOT_SUPPORTED }); + await expect( + workspace.moveFile("source.txt", "move.txt", { etag: "etag" }), + ).rejects.toMatchObject({ code: StorageErrorCode.NOT_SUPPORTED }); + }); + + it("narrows dependent permissions and logical limits across mounts", async () => { + const { cipher, client, storage, workspace } = fixture(); + await workspace.writeFile("child/existing.txt", "123456", { mode: "create" }); + const child = workspace.mount("child", { + limits: { + maxPageSize: 2, + maxPathBytes: 16, + maxReadBytes: 10, + maxWriteBytes: 5, + }, + permissions: ["list", "read", "create"], + }); + await expect(child.writeFile("five.txt", "12345", { mode: "create" })).resolves.toMatchObject({ + size: 5, + }); + await expect(child.writeFile("six.txt", "123456", { mode: "create" })).rejects.toMatchObject({ + code: StorageErrorCode.LIMIT_EXCEEDED, + }); + await expect(child.list({ limit: 3 })).rejects.toMatchObject({ + code: StorageErrorCode.LIMIT_EXCEEDED, + }); + await expect( + child.writeFile("this-path-is-too-long.txt", "1", { mode: "create" }), + ).rejects.toMatchObject({ code: StorageErrorCode.INVALID_ARGUMENT }); + await expect(workspace.readText("child/five.txt")).resolves.toMatchObject({ text: "12345" }); + await expect(child.readText("existing.txt")).resolves.toMatchObject({ text: "123456" }); + const reconstructed = protectedView(storage, cipher, { + limits: Object.freeze({ + ...LOGICAL_LIMITS, + maxReadBytes: 10, + maxWriteBytes: 5, + }), + }); + await expect(reconstructed.readText("child/existing.txt")).resolves.toMatchObject({ + text: "123456", + }); + expect(child.permissions).toEqual(new Set(["list", "read", "create"])); + expect(() => child.mount("forbidden", { permissions: ["copy"] })).toThrowError( + expect.objectContaining({ code: StorageErrorCode.UNAUTHORIZED }), + ); + + const listOnlyStorage = backing(client, "list-only", ["list"]); + const listOnly = protectedView(listOnlyStorage, cipher, { scopeContext: "list-only" }); + expect(listOnly.allows("list")).toBe(false); + expect(listOnly.permissions.has("list")).toBe(false); + + const replaceOnlyStorage = backing(client, "replace-only", ["replace"]); + const replaceOnly = protectedView(replaceOnlyStorage, cipher, { scopeContext: "replace-only" }); + expect(replaceOnly.allows("replace")).toBe(false); + + const searchWithoutReadStorage = backing(client, "search-only", ["search"]); + const searchWithoutRead = protectedView(searchWithoutReadStorage, cipher, { + pathSearch: "provider-visible", + scopeContext: "search-only", + }); + expect(searchWithoutRead.allows("search")).toBe(false); + }); + + it("enforces lifetime/caller cancellation and one whole-operation timeout", async () => { + const { cipher, storage } = fixture(); + const lifetime = new AbortController(); + const workspace = protectedView(storage, cipher, { signal: lifetime.signal }); + const listenersBefore = getEventListeners(lifetime.signal, "abort").length; + await workspace.writeFile("before.txt", "protected", { + mode: "create", + signal: lifetime.signal, + }); + expect(getEventListeners(lifetime.signal, "abort")).toHaveLength(listenersBefore); + const child = workspace.mount("child"); + lifetime.abort("do-not-expose-this-reason"); + const backingRead = vi.spyOn(storage, "readText"); + + await expect(workspace.readText("before.txt")).rejects.toMatchObject({ + aborted: true, + cause: undefined, + code: StorageErrorCode.ABORTED, + message: "Protected workspace read failed.", + }); + expect(backingRead).not.toHaveBeenCalled(); + await expect(child.list()).rejects.toMatchObject({ + aborted: true, + code: StorageErrorCode.ABORTED, + }); + expect(() => workspace.mount("late")).toThrowError( + expect.objectContaining({ aborted: true, code: StorageErrorCode.ABORTED }), + ); + + const caller = new AbortController(); + caller.abort({ secret: "reason" }); + const fresh = protectedView(storage, cipher); + const backingList = vi.spyOn(storage, "list"); + await expect(fresh.list({ signal: caller.signal })).rejects.toMatchObject({ + aborted: true, + cause: undefined, + code: StorageErrorCode.ABORTED, + }); + expect(backingList).not.toHaveBeenCalled(); + await expect(fresh.list({ timeout: Number.MAX_SAFE_INTEGER })).rejects.toMatchObject({ + code: StorageErrorCode.INVALID_ARGUMENT, + }); + expect(backingList).not.toHaveBeenCalled(); + + const encryptSpy = vi.fn(cipher.encryptBytes.bind(cipher)); + const guardedCipher: AuthenticatedWorkspaceCipher = { + decryptBytes: vi.fn(cipher.decryptBytes.bind(cipher)), + encryptBytes: encryptSpy, + }; + const guarded = protectedView(storage, guardedCipher, { scopeContext: "pre-aborted" }); + await expect( + guarded.writeFile("never-started.txt", "secret", { + mode: "create", + signal: caller.signal, + }), + ).rejects.toMatchObject({ code: StorageErrorCode.ABORTED }); + expect(encryptSpy).not.toHaveBeenCalled(); + + const hangingCipher: AuthenticatedWorkspaceCipher = Object.freeze({ + decryptBytes: async () => await new Promise(() => undefined), + encryptBytes: async () => await new Promise(() => undefined), + }); + const hanging = protectedView(storage, hangingCipher, { scopeContext: "timeout" }); + await expect( + hanging.writeFile("timeout.txt", "never", { mode: "create", timeout: 10 }), + ).rejects.toMatchObject({ + aborted: true, + code: StorageErrorCode.TIMEOUT, + timedOut: true, + }); + }); + + it("enforces separate logical and physical bounds", async () => { + const { cipher, storage } = fixture(); + expect(() => + protectedView(storage, cipher, { + limits: Object.freeze({ ...LOGICAL_LIMITS, maxPageSize: 1 }), + }), + ).toThrowError(/must match its backing workspace limit/u); + expect(() => + protectedView(storage, cipher, { + maxCiphertextBytes: BACKING_LIMITS.maxReadBytes + 1, + }), + ).toThrowError(/ciphertext limit/u); + + const tinyLimits: Readonly = Object.freeze({ + ...LOGICAL_LIMITS, + maxReadBytes: 8, + maxWriteBytes: 8, + }); + const tinyPhysical = protectedView(storage, cipher, { + limits: tinyLimits, + maxCiphertextBytes: 8, + scopeContext: "tiny", + }); + await expect( + tinyPhysical.writeFile("tiny.txt", "12345678", { mode: "create" }), + ).rejects.toMatchObject({ code: StorageErrorCode.LIMIT_EXCEEDED }); + await expect( + tinyPhysical.writeFile("too-large.txt", "123456789", { mode: "create" }), + ).rejects.toMatchObject({ code: StorageErrorCode.LIMIT_EXCEEDED }); + }); + + it("sanitizes backing and cipher failures at the public boundary", async () => { + const { cipher, storage, workspace } = fixture(); + vi.spyOn(storage, "readText").mockRejectedValueOnce( + new StorageError("credential=provider-secret", { + cause: new Error("provider stack secret"), + code: StorageErrorCode.PROVIDER, + key: "physical/provider/key", + operation: "download", + }), + ); + await expect(workspace.readText("logical.txt")).rejects.toMatchObject({ + cause: undefined, + code: StorageErrorCode.PROVIDER, + key: "logical.txt", + message: "Protected workspace read failed.", + operation: "read", + }); + + await workspace.writeFile("cipher.txt", "protected", { mode: "create" }); + const failingCipher: AuthenticatedWorkspaceCipher = Object.freeze({ + decryptBytes: async () => { + throw new Error("kms credential and ciphertext details"); + }, + encryptBytes: cipher.encryptBytes.bind(cipher), + }); + const failing = protectedView(storage, failingCipher); + await expect(failing.stat("cipher.txt")).rejects.toMatchObject({ + cause: undefined, + code: StorageErrorCode.PROVIDER, + message: "Protected workspace stat failed.", + }); + }); +}); + +describe("CipherEngine workspace cipher adapter", () => { + it("fails configuration that could write envelopes its read policy rejects", () => { + const testEngine = engine(); + expect(() => + createCipherEngineWorkspaceCipher(testEngine, { + allowedProviders: ["legacy"], + }), + ).toThrowError(/write provider/u); + expect(() => + createCipherEngineWorkspaceCipher(testEngine, { + allowedProviders: ["missing"], + provider: "missing", + }), + ).toThrowError(/not registered/u); + }); +}); diff --git a/src/storage-workspace/protected-storage-workspace.ts b/src/storage-workspace/protected-storage-workspace.ts new file mode 100644 index 0000000..3de8ef1 --- /dev/null +++ b/src/storage-workspace/protected-storage-workspace.ts @@ -0,0 +1,1329 @@ +import { randomUUID } from "node:crypto"; + +import { + StorageError, + StorageErrorCode, + isStorageError, + type StorageErrorCode as StorageErrorCodeValue, + type StorageOperationOptions, +} from "@nestm/storage/core"; +import type { + StorageWorkspace, + StorageWorkspaceBody, + StorageWorkspaceCopyOptions, + StorageWorkspaceDeleteOptions, + StorageWorkspaceEntry, + StorageWorkspaceFile, + StorageWorkspaceLimits, + StorageWorkspaceListOptions, + StorageWorkspaceMountOptions, + StorageWorkspaceMutationOptions, + StorageWorkspacePage, + StorageWorkspacePermission, + StorageWorkspaceReadOptions, + StorageWorkspaceSearchOptions, + StorageWorkspaceTextFile, + StorageWorkspaceWriteOptions, +} from "@nestm/storage/workspace"; + +import type { + AuthenticatedWorkspaceCipher, + AuthenticatedWorkspaceCipherContext, + ProtectedStorageWorkspace, + ProtectedStorageWorkspaceByteFile, + ProtectedStorageWorkspacePathSearch, + ProtectedStorageWorkspaceProtection, + ProtectStorageWorkspaceOptions, +} from "./types.js"; + +const OUTER_VERSION = 1 as const; +const OUTER_CONTENT_TYPE = "application/octet-stream"; +const CIPHER_ENVELOPE_PREFIX = "nmc1."; +const CONTEXT_DOMAIN = "nestm:crypto:protected-storage-workspace:v1"; +const MAX_SCOPE_CONTEXT_BYTES = 4096; +const MAX_POLICY_REVISION_BYTES = 256; +const MAX_CONTENT_TYPE_BYTES = 255; +const MAX_OPERATION_TIMEOUT_MS = 2_147_483_647; +const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const textEncoder = new TextEncoder(); +const strictTextDecoder = new TextDecoder("utf-8", { fatal: true }); +const FORBIDDEN_PERMISSIONS = new Set(["copy", "move"]); + +type ProtectedPurpose = "metadata" | "body"; + +interface ProtectedOuterObject { + readonly v: typeof OUTER_VERSION; + readonly record: string; + readonly version: string; + readonly metadata: string; + readonly content: string; +} + +interface ProtectedMetadata { + readonly contentType: string; + readonly path: string; + readonly size: number; +} + +interface AuthenticatedMetadata { + readonly contentType: string; + readonly outer: ProtectedOuterObject; + readonly size: number; +} + +interface OperationRuntime { + readonly signal: AbortSignal | undefined; + readonly timedOut: () => boolean; + readonly close: () => void; +} + +interface CapturedOptions { + readonly storage: StorageWorkspace; + readonly cipher: AuthenticatedWorkspaceCipher; + readonly scopeContext: Uint8Array; + readonly policyRevision: string; + readonly limits: Readonly; + readonly maxCiphertextBytes: number; + readonly maxPlaintextBytes: number; + readonly signal?: AbortSignal; + readonly pathSearch: ProtectedStorageWorkspacePathSearch; +} + +/** + * Build a strict encrypted-only view over an existing StorageWorkspace. + * + * The backing workspace remains the authority for paths, CAS, cursors, and + * provider access. This wrapper owns only authenticated serialization and + * logical plaintext projection. + */ +export function protectStorageWorkspace( + options: ProtectStorageWorkspaceOptions, +): ProtectedStorageWorkspace { + const captured = captureOptions(options); + try { + captured.signal?.throwIfAborted(); + } catch { + captured.scopeContext.fill(0); + throw protectedError(StorageErrorCode.ABORTED, "protect", undefined, { + aborted: true, + }); + } + try { + return new ProtectedStorageWorkspaceImplementation(captured, ""); + } finally { + captured.scopeContext.fill(0); + } +} + +class ProtectedStorageWorkspaceImplementation implements ProtectedStorageWorkspace { + readonly #storage: StorageWorkspace; + readonly #cipher: AuthenticatedWorkspaceCipher; + readonly #scopeContext: Uint8Array; + readonly #policyRevision: string; + readonly #directory: string; + readonly #maxCiphertextBytes: number; + readonly #maxPlaintextBytes: number; + readonly #lifetimeSignal: AbortSignal | undefined; + readonly #pathSearch: ProtectedStorageWorkspacePathSearch; + readonly limits: Readonly; + readonly protection: Readonly; + + constructor(options: CapturedOptions, directory: string) { + this.#storage = options.storage; + this.#cipher = options.cipher; + this.#scopeContext = new Uint8Array(options.scopeContext); + this.#policyRevision = options.policyRevision; + this.#directory = directory; + this.#maxCiphertextBytes = options.maxCiphertextBytes; + this.#maxPlaintextBytes = options.maxPlaintextBytes; + this.#lifetimeSignal = options.signal; + this.#pathSearch = options.pathSearch; + this.limits = Object.freeze({ ...options.limits }); + this.protection = Object.freeze({ + body: "encrypted", + cipherEnvelope: "nmc1", + kind: "authenticated-encryption", + metadata: "encrypted", + outerFormat: "nestm-protected-storage-workspace", + outerVersion: OUTER_VERSION, + pathBinding: "scope-policy-path-record-version-purpose", + pathSearch: options.pathSearch, + policyRevision: options.policyRevision, + }); + Object.freeze(this); + } + + get permissions(): ReadonlySet { + return new Set( + [...this.#storage.permissions].filter((permission) => this.#permissionAllowed(permission)), + ); + } + + allows(permission: StorageWorkspacePermission): boolean { + return this.#permissionAllowed(permission); + } + + async stat(path: string, options?: StorageOperationOptions): Promise { + return await this.#operate("stat", path, options, async (runtime) => { + this.#require("read", "stat", path); + const stored = await this.#readStored(path, options, runtime.signal); + const metadata = await this.#decryptMetadata(path, stored.text, runtime.signal); + return logicalFile(path, metadata, stored); + }); + } + + async readText( + path: string, + options?: StorageWorkspaceReadOptions, + ): Promise { + return await this.#operate("read", path, options, async (runtime) => { + this.#require("read", "read", path); + const file = await this.#readBytes(path, options, runtime.signal); + try { + return { ...withoutBytes(file), text: strictTextDecoder.decode(file.bytes) }; + } catch { + throw protectedError(StorageErrorCode.INVALID_ARGUMENT, "read", path, { permanent: true }); + } finally { + file.bytes.fill(0); + } + }); + } + + async readBytes( + path: string, + options?: StorageWorkspaceReadOptions, + ): Promise { + return await this.#operate("read", path, options, async (runtime) => { + this.#require("read", "read", path); + return await this.#readBytes(path, options, runtime.signal); + }); + } + + async list(options?: StorageWorkspaceListOptions): Promise { + return await this.#operate("list", options?.directory, options, async (runtime) => { + this.#require("list", "list", options?.directory); + const page = await this.#storage.list(listOptions(options, runtime.signal)); + return await this.#authenticatePage(page, operationOptions(options, runtime.signal)); + }); + } + + async search( + query: string, + options?: StorageWorkspaceSearchOptions, + ): Promise { + return await this.#operate("search", options?.directory, options, async (runtime) => { + if (this.#pathSearch !== "provider-visible") { + throw protectedError(StorageErrorCode.NOT_SUPPORTED, "search", options?.directory, { + permanent: true, + }); + } + this.#require("search", "search", options?.directory); + const page = await this.#storage.search(query, searchOptions(options, runtime.signal)); + return await this.#authenticatePage(page, operationOptions(options, runtime.signal)); + }); + } + + async writeFile( + path: string, + body: StorageWorkspaceBody, + options: StorageWorkspaceWriteOptions, + ): Promise { + return await this.#operate("write", path, options, async (runtime) => { + const mode = captureWriteMode(options, path); + this.#require(writePermission(mode), "write", path); + assertLogicalWritePath(path, this.limits.maxPathBytes); + if (Object.hasOwn(options, "metadata")) { + throw protectedError(StorageErrorCode.INVALID_ARGUMENT, "write", path, { + permanent: true, + }); + } + const plaintext = captureBody(body, path, this.limits.maxWriteBytes); + const clearPlaintext = (): void => { + plaintext.fill(0); + }; + runtime.signal?.addEventListener("abort", clearPlaintext, { once: true }); + try { + if (plaintext.byteLength > this.limits.maxWriteBytes) { + throw protectedError(StorageErrorCode.LIMIT_EXCEEDED, "write", path, { + permanent: true, + }); + } + const contentType = captureContentType(options.contentType ?? OUTER_CONTENT_TYPE, path); + let record: string = randomUUID(); + if (mode === "replace") { + const current = await this.#readStored(path, options, runtime.signal); + const authenticated = await this.#decryptMetadata(path, current.text, runtime.signal); + record = authenticated.outer.record; + } + const outer = await this.#encryptObject( + path, + plaintext, + contentType, + record, + randomUUID(), + runtime.signal, + ); + if (textEncoder.encode(outer).byteLength > this.#maxCiphertextBytes) { + throw protectedError(StorageErrorCode.LIMIT_EXCEEDED, "write", path, { + permanent: true, + }); + } + const stored = await this.#storage.writeFile( + path, + outer, + storageWriteOptions(options, mode, runtime.signal), + ); + return logicalFile(path, { contentType, size: plaintext.byteLength }, stored); + } finally { + runtime.signal?.removeEventListener("abort", clearPlaintext); + clearPlaintext(); + } + }); + } + + async copyFile( + _source: string, + destination: string, + options: StorageWorkspaceCopyOptions, + ): Promise { + return await this.#operate("copy", destination, options, async () => { + throw protectedError(StorageErrorCode.NOT_SUPPORTED, "copy", destination, { + permanent: true, + }); + }); + } + + async moveFile( + _source: string, + destination: string, + options: StorageWorkspaceMutationOptions, + ): Promise { + return await this.#operate("move", destination, options, async () => { + throw protectedError(StorageErrorCode.NOT_SUPPORTED, "move", destination, { + permanent: true, + }); + }); + } + + async deleteFile(path: string, options: StorageWorkspaceDeleteOptions): Promise { + await this.#operate("delete", path, options, async (runtime) => { + this.#require("delete", "delete", path); + await this.#storage.deleteFile(path, storageDeleteOptions(options, runtime.signal)); + }); + } + + mount(directory: string, options?: StorageWorkspaceMountOptions): ProtectedStorageWorkspace { + this.#throwIfLifetimeAborted("mount", directory); + try { + const limits = restrictLimits(this.limits, options?.limits); + const permissions = restrictPermissions(this.permissions, options?.permissions); + const storage = this.#storage.mount(directory, { + permissions, + limits: { + ...limits, + maxReadBytes: this.#storage.limits.maxReadBytes, + maxWriteBytes: this.#storage.limits.maxWriteBytes, + }, + }); + return new ProtectedStorageWorkspaceImplementation( + { + cipher: this.#cipher, + limits, + maxCiphertextBytes: this.#maxCiphertextBytes, + maxPlaintextBytes: this.#maxPlaintextBytes, + pathSearch: this.#pathSearch, + policyRevision: this.#policyRevision, + scopeContext: this.#scopeContext, + ...(this.#lifetimeSignal === undefined ? {} : { signal: this.#lifetimeSignal }), + storage, + }, + joinPath(this.#directory, directory), + ); + } catch (error: unknown) { + throw sanitizeError(error, "mount", directory, false, this.#lifetimeSignal?.aborted === true); + } + } + + #permissionAllowed(permission: StorageWorkspacePermission): boolean { + if (FORBIDDEN_PERMISSIONS.has(permission) || !this.#storage.allows(permission)) { + return false; + } + if (permission === "search") { + return this.#pathSearch === "provider-visible" && this.#storage.allows("read"); + } + if (permission === "list" || permission === "replace") { + return this.#storage.allows("read"); + } + return true; + } + + #require( + permission: StorageWorkspacePermission, + operation: string, + path: string | undefined, + ): void { + if (!this.#permissionAllowed(permission)) { + throw protectedError(StorageErrorCode.UNAUTHORIZED, operation, path, { + permanent: true, + }); + } + } + + async #readBytes( + path: string, + options: StorageWorkspaceReadOptions | undefined, + signal: AbortSignal | undefined, + ): Promise { + const maxBytes = options?.maxBytes ?? this.limits.maxReadBytes; + assertReadLimit(maxBytes, this.limits.maxReadBytes, path); + const stored = await this.#readStored(path, options, signal); + const metadata = await this.#decryptMetadata(path, stored.text, signal); + if (metadata.size > maxBytes) { + throw protectedError(StorageErrorCode.LIMIT_EXCEEDED, "read", path, { + permanent: true, + }); + } + let plaintext: Uint8Array | undefined; + try { + plaintext = await this.#decryptValue( + metadata.outer.content, + path, + metadata.outer.record, + metadata.outer.version, + "body", + signal, + ); + if (!isStableBytes(plaintext)) { + throw new TypeError("The workspace cipher returned invalid plaintext."); + } + const bytes = new Uint8Array(plaintext); + if (bytes.byteLength !== metadata.size || bytes.byteLength > this.#maxPlaintextBytes) { + bytes.fill(0); + throw new TypeError("Protected body size is invalid."); + } + return { ...logicalFile(path, metadata, stored), bytes }; + } catch (error: unknown) { + throw authenticationError("read", path, error); + } finally { + plaintext?.fill(0); + } + } + + async #readStored( + path: string, + options: StorageOperationOptions | undefined, + signal: AbortSignal | undefined, + ): Promise { + return await this.#storage.readText(path, { + ...operationOptions(options, signal), + maxBytes: this.#maxCiphertextBytes, + }); + } + + async #authenticatePage( + page: StorageWorkspacePage, + options: StorageOperationOptions | undefined, + ): Promise { + const entries: StorageWorkspaceEntry[] = []; + for (const entry of page.entries) { + if (entry.kind === "directory") { + entries.push(entry); + continue; + } + const stored = await this.#readStored(entry.path, options, options?.signal); + const metadata = await this.#decryptMetadata(entry.path, stored.text, options?.signal); + entries.push(logicalFile(entry.path, metadata, stored)); + } + return { + entries, + ...(page.cursor === undefined ? {} : { cursor: page.cursor }), + }; + } + + async #encryptObject( + path: string, + body: Uint8Array, + contentType: string, + record: string, + version: string, + signal: AbortSignal | undefined, + ): Promise { + const fullPath = joinPath(this.#directory, path); + const metadataBytes = textEncoder.encode( + JSON.stringify({ + contentType, + path: fullPath, + size: body.byteLength, + } satisfies ProtectedMetadata), + ); + try { + const metadata = await this.#encryptValue( + metadataBytes, + path, + record, + version, + "metadata", + signal, + ); + const encryptedBody = await this.#encryptValue(body, path, record, version, "body", signal); + assertCipherEnvelope(metadata); + assertCipherEnvelope(encryptedBody); + return JSON.stringify({ + v: OUTER_VERSION, + record, + version, + metadata, + content: encryptedBody, + } satisfies ProtectedOuterObject); + } catch (error: unknown) { + throw authenticationError("write", path, error); + } finally { + metadataBytes.fill(0); + } + } + + async #decryptMetadata( + path: string, + stored: string, + signal: AbortSignal | undefined, + ): Promise { + let plaintext: Uint8Array | undefined; + try { + const outer = parseOuter(stored, this.#maxCiphertextBytes); + plaintext = await this.#decryptValue( + outer.metadata, + path, + outer.record, + outer.version, + "metadata", + signal, + ); + if (!isStableBytes(plaintext)) { + throw new TypeError("The workspace cipher returned invalid metadata."); + } + const metadata = parseMetadata( + plaintext, + joinPath(this.#directory, path), + this.#maxPlaintextBytes, + ); + return { contentType: metadata.contentType, outer, size: metadata.size }; + } catch (error: unknown) { + throw authenticationError("read", path, error); + } finally { + plaintext?.fill(0); + } + } + + #cipherContext( + path: string, + record: string, + version: string, + purpose: ProtectedPurpose, + signal: AbortSignal | undefined, + ): AuthenticatedWorkspaceCipherContext { + const shared = [ + this.#scopeContext, + textEncoder.encode(this.#policyRevision), + textEncoder.encode(joinPath(this.#directory, path)), + textEncoder.encode(record), + textEncoder.encode(version), + textEncoder.encode(purpose), + ]; + return { + authenticatedData: frameContext("aad", shared), + keyContext: frameContext("key-context", shared), + ...(signal === undefined ? {} : { signal }), + }; + } + + async #encryptValue( + plaintext: Uint8Array, + path: string, + record: string, + version: string, + purpose: ProtectedPurpose, + signal: AbortSignal | undefined, + ): Promise { + const context = this.#cipherContext(path, record, version, purpose, signal); + const clearContext = (): void => { + context.authenticatedData.fill(0); + context.keyContext.fill(0); + }; + signal?.addEventListener("abort", clearContext, { once: true }); + try { + const encrypted = await this.#cipher.encryptBytes(plaintext, context); + if (signal?.aborted) throw signal.reason; + return encrypted; + } finally { + signal?.removeEventListener("abort", clearContext); + clearContext(); + } + } + + async #decryptValue( + envelope: string, + path: string, + record: string, + version: string, + purpose: ProtectedPurpose, + signal: AbortSignal | undefined, + ): Promise { + const context = this.#cipherContext(path, record, version, purpose, signal); + const clearContext = (): void => { + context.authenticatedData.fill(0); + context.keyContext.fill(0); + }; + signal?.addEventListener("abort", clearContext, { once: true }); + try { + const plaintext = await this.#cipher.decryptBytes(envelope, context); + if (signal?.aborted) { + if (isStableBytes(plaintext)) plaintext.fill(0); + throw signal.reason; + } + return plaintext; + } finally { + signal?.removeEventListener("abort", clearContext); + clearContext(); + } + } + + async #operate( + operation: string, + path: string | undefined, + options: StorageOperationOptions | undefined, + work: (runtime: OperationRuntime) => Promise, + ): Promise { + const runtime = operationRuntime(this.#lifetimeSignal, options, operation, path); + try { + return await raceWithAbort(async () => await work(runtime), runtime.signal); + } catch (error: unknown) { + throw sanitizeError( + error, + operation, + path, + runtime.timedOut(), + runtime.signal?.aborted === true, + ); + } finally { + runtime.close(); + } + } + + #throwIfLifetimeAborted(operation: string, path?: string): void { + if (this.#lifetimeSignal?.aborted) { + throw protectedError(StorageErrorCode.ABORTED, operation, path, { + aborted: true, + }); + } + } +} + +function captureOptions(options: ProtectStorageWorkspaceOptions): CapturedOptions { + if (typeof options !== "object" || options === null) { + throw new TypeError("Protected storage-workspace options are required."); + } + const storage = options.storage; + if ( + typeof storage !== "object" || + storage === null || + typeof storage.readText !== "function" || + typeof storage.writeFile !== "function" || + typeof storage.mount !== "function" + ) { + throw new TypeError("A StorageWorkspace is required."); + } + const cipher = options.cipher; + if ( + typeof cipher !== "object" || + cipher === null || + typeof cipher.encryptBytes !== "function" || + typeof cipher.decryptBytes !== "function" + ) { + throw new TypeError("An authenticated workspace cipher is required."); + } + const signal = options.signal; + assertOptionalSignal(signal); + const policyRevision = capturePolicyRevision(options.policyRevision); + const scopeContext = captureScopeContext(options.scopeContext); + const limits = captureLimits(options.limits, storage.limits); + const maxCiphertextBytes = positiveInteger( + options.maxCiphertextBytes, + "The protected workspace ciphertext limit", + ); + const maxPlaintextBytes = Math.max(limits.maxReadBytes, limits.maxWriteBytes); + if ( + maxCiphertextBytes < maxPlaintextBytes || + maxCiphertextBytes > storage.limits.maxReadBytes || + maxCiphertextBytes > storage.limits.maxWriteBytes + ) { + scopeContext.fill(0); + throw new TypeError( + "The ciphertext limit must cover logical plaintext and fit the backing workspace read/write limits.", + ); + } + const pathSearch = options.pathSearch ?? "disabled"; + if (pathSearch !== "disabled" && pathSearch !== "provider-visible") { + scopeContext.fill(0); + throw new TypeError("Protected workspace path search is invalid."); + } + return { + cipher, + limits, + maxCiphertextBytes, + maxPlaintextBytes, + pathSearch, + policyRevision, + scopeContext, + ...(signal === undefined ? {} : { signal }), + storage, + }; +} + +function captureLimits( + value: Readonly, + backing: Readonly, +): Readonly { + if (typeof value !== "object" || value === null) { + throw new TypeError("Protected workspace logical limits are required."); + } + const result = { + cursorTtlMs: captureInitialLimit("cursorTtlMs", value, backing, false), + maxCursorBytes: captureInitialLimit("maxCursorBytes", value, backing, false), + maxPageSize: captureInitialLimit("maxPageSize", value, backing, false), + maxPathBytes: captureInitialLimit("maxPathBytes", value, backing, false), + maxReadBytes: captureInitialLimit("maxReadBytes", value, backing, true), + maxSearchResults: captureInitialLimit("maxSearchResults", value, backing, false), + maxSearchScan: captureInitialLimit("maxSearchScan", value, backing, false), + maxWriteBytes: captureInitialLimit("maxWriteBytes", value, backing, true), + } satisfies StorageWorkspaceLimits; + return Object.freeze(result); +} + +function captureInitialLimit( + key: keyof StorageWorkspaceLimits, + value: Readonly, + backing: Readonly, + allowNarrowing: boolean, +): number { + const limit = positiveInteger(value[key], `The logical ${key} limit`); + if (limit > backing[key]) { + throw new TypeError(`The logical ${key} limit cannot exceed its backing workspace limit.`); + } + if (!allowNarrowing && limit !== backing[key]) { + throw new TypeError(`The initial logical ${key} limit must match its backing workspace limit.`); + } + return limit; +} + +function captureScopeContext(value: string | Uint8Array): Uint8Array { + let bytes: Uint8Array; + if (typeof value === "string") { + bytes = boundedUtf8(value, MAX_SCOPE_CONTEXT_BYTES, "Protected workspace scope context"); + } else if (isStableBytes(value)) { + if (value.byteLength === 0 || value.byteLength > MAX_SCOPE_CONTEXT_BYTES) { + throw new TypeError("Protected workspace scope context has an invalid size."); + } + bytes = new Uint8Array(value); + } else { + throw new TypeError("Protected workspace scope context must be text or bytes."); + } + if (bytes.byteLength === 0 || bytes.byteLength > MAX_SCOPE_CONTEXT_BYTES) { + bytes.fill(0); + throw new TypeError("Protected workspace scope context has an invalid size."); + } + return bytes; +} + +function capturePolicyRevision(value: string): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_POLICY_REVISION_BYTES || + value.trim() !== value || + /\p{C}/u.test(value) + ) { + throw new TypeError("Protected workspace policy revision is invalid."); + } + const bytes = boundedUtf8( + value, + MAX_POLICY_REVISION_BYTES, + "Protected workspace policy revision", + ); + bytes.fill(0); + return value; +} + +function captureWriteMode( + options: StorageWorkspaceWriteOptions, + path: string, +): StorageWorkspaceWriteOptions["mode"] { + if (typeof options !== "object" || options === null) { + throw protectedError(StorageErrorCode.INVALID_ARGUMENT, "write", path, { + permanent: true, + }); + } + if (options.mode !== "create" && options.mode !== "replace" && options.mode !== "overwrite") { + throw protectedError(StorageErrorCode.INVALID_ARGUMENT, "write", path, { + permanent: true, + }); + } + if ( + options.mode === "replace" && + (typeof options.etag !== "string" || options.etag.length === 0) + ) { + throw protectedError(StorageErrorCode.INVALID_ARGUMENT, "write", path, { + permanent: true, + }); + } + if (options.mode !== "replace" && Object.hasOwn(options, "etag")) { + throw protectedError(StorageErrorCode.INVALID_ARGUMENT, "write", path, { + permanent: true, + }); + } + return options.mode; +} + +function writePermission(mode: StorageWorkspaceWriteOptions["mode"]): StorageWorkspacePermission { + if (mode === "overwrite") return "write"; + return mode; +} + +function storageWriteOptions( + options: StorageWorkspaceWriteOptions, + mode: StorageWorkspaceWriteOptions["mode"], + signal: AbortSignal | undefined, +): StorageWorkspaceWriteOptions { + const operation = operationOptions(options, signal); + if (mode === "create") { + return { ...operation, contentType: OUTER_CONTENT_TYPE, mode: "create" }; + } + if (mode === "overwrite") { + return { ...operation, contentType: OUTER_CONTENT_TYPE, mode: "overwrite" }; + } + if (options.mode !== "replace") { + throw new TypeError("Protected workspace replace options are invalid."); + } + return { + ...operation, + contentType: OUTER_CONTENT_TYPE, + etag: options.etag, + mode: "replace", + }; +} + +function captureBody(body: StorageWorkspaceBody, path: string, maxBytes: number): Uint8Array { + if (typeof body === "string") { + try { + return boundedUtf8(body, maxBytes, "Protected workspace body"); + } catch { + throw protectedError(StorageErrorCode.LIMIT_EXCEEDED, "write", path, { + permanent: true, + }); + } + } + if (!isStableBytes(body)) { + throw protectedError(StorageErrorCode.INVALID_ARGUMENT, "write", path, { + permanent: true, + }); + } + if (body.byteLength > maxBytes) { + throw protectedError(StorageErrorCode.LIMIT_EXCEEDED, "write", path, { + permanent: true, + }); + } + return new Uint8Array(body); +} + +function assertLogicalWritePath(path: string, maxBytes: number): void { + if (typeof path !== "string" || path.length === 0 || path.length > maxBytes) { + throw protectedError(StorageErrorCode.INVALID_ARGUMENT, "write", undefined, { + permanent: true, + }); + } + try { + const bytes = boundedUtf8(path, maxBytes, "Protected workspace path"); + bytes.fill(0); + } catch { + throw protectedError(StorageErrorCode.INVALID_ARGUMENT, "write", undefined, { + permanent: true, + }); + } +} + +function captureContentType(value: string, path: string): string { + if (!isCanonicalContentType(value)) { + throw protectedError(StorageErrorCode.INVALID_ARGUMENT, "write", path, { + permanent: true, + }); + } + return value; +} + +function isCanonicalContentType(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_CONTENT_TYPE_BYTES || + value.trim() !== value || + /\p{C}/u.test(value) + ) { + return false; + } + try { + const bytes = boundedUtf8(value, MAX_CONTENT_TYPE_BYTES, "Protected content type"); + bytes.fill(0); + } catch { + return false; + } + return true; +} + +function parseOuter(stored: string, maxCiphertextBytes: number): ProtectedOuterObject { + if (typeof stored !== "string" || textEncoder.encode(stored).byteLength > maxCiphertextBytes) { + throw new TypeError("Protected outer object exceeds its physical limit."); + } + const parsed: unknown = JSON.parse(stored); + if (!isRecord(parsed) || Object.keys(parsed).join(",") !== "v,record,version,metadata,content") { + throw new TypeError("Protected outer object is malformed."); + } + if ( + parsed.v !== OUTER_VERSION || + typeof parsed.record !== "string" || + !UUID_V4_PATTERN.test(parsed.record) || + typeof parsed.version !== "string" || + !UUID_V4_PATTERN.test(parsed.version) || + typeof parsed.metadata !== "string" || + typeof parsed.content !== "string" + ) { + throw new TypeError("Protected outer object is malformed."); + } + assertCipherEnvelope(parsed.metadata); + assertCipherEnvelope(parsed.content); + return { + content: parsed.content, + metadata: parsed.metadata, + record: parsed.record, + v: OUTER_VERSION, + version: parsed.version, + }; +} + +function parseMetadata( + bytes: Uint8Array, + expectedPath: string, + maxPlaintextBytes: number, +): ProtectedMetadata { + const parsed: unknown = JSON.parse(strictTextDecoder.decode(bytes)); + if (!isRecord(parsed) || Object.keys(parsed).join(",") !== "contentType,path,size") { + throw new TypeError("Protected metadata is malformed."); + } + const size = parsed.size; + if ( + !isCanonicalContentType(parsed.contentType) || + parsed.path !== expectedPath || + typeof size !== "number" || + !Number.isSafeInteger(size) || + size < 0 || + size > maxPlaintextBytes + ) { + throw new TypeError("Protected metadata is malformed."); + } + return { contentType: parsed.contentType, path: expectedPath, size }; +} + +function assertCipherEnvelope(value: string): void { + if (typeof value !== "string" || !value.startsWith(CIPHER_ENVELOPE_PREFIX)) { + throw new TypeError("The workspace cipher must return an nmc1 envelope."); + } +} + +function logicalFile( + path: string, + logical: Pick, + stored: Pick, +): StorageWorkspaceFile { + const name = path.slice(path.lastIndexOf("/") + 1); + return { + contentType: logical.contentType, + ...(stored.etag === undefined ? {} : { etag: stored.etag }), + kind: "file", + ...(stored.lastModified === undefined ? {} : { lastModified: new Date(stored.lastModified) }), + name, + path, + size: logical.size, + }; +} + +function withoutBytes(file: ProtectedStorageWorkspaceByteFile): StorageWorkspaceFile { + return { + contentType: file.contentType, + ...(file.etag === undefined ? {} : { etag: file.etag }), + kind: "file", + ...(file.lastModified === undefined ? {} : { lastModified: file.lastModified }), + name: file.name, + path: file.path, + size: file.size, + }; +} + +function assertReadLimit(value: number, maximum: number, path: string): void { + if (!Number.isSafeInteger(value) || value <= 0) { + throw protectedError(StorageErrorCode.INVALID_ARGUMENT, "read", path, { + permanent: true, + }); + } + if (value > maximum) { + throw protectedError(StorageErrorCode.LIMIT_EXCEEDED, "read", path, { + permanent: true, + }); + } +} + +function restrictLimits( + parent: Readonly, + requested: Partial | undefined, +): Readonly { + const result = { + cursorTtlMs: childLimit(requested?.cursorTtlMs, parent.cursorTtlMs), + maxCursorBytes: childLimit(requested?.maxCursorBytes, parent.maxCursorBytes), + maxPageSize: childLimit(requested?.maxPageSize, parent.maxPageSize), + maxPathBytes: childLimit(requested?.maxPathBytes, parent.maxPathBytes), + maxReadBytes: childLimit(requested?.maxReadBytes, parent.maxReadBytes), + maxSearchResults: childLimit(requested?.maxSearchResults, parent.maxSearchResults), + maxSearchScan: childLimit(requested?.maxSearchScan, parent.maxSearchScan), + maxWriteBytes: childLimit(requested?.maxWriteBytes, parent.maxWriteBytes), + } satisfies StorageWorkspaceLimits; + return Object.freeze(result); +} + +function childLimit(value: number | undefined, parent: number): number { + if (value === undefined) return parent; + if (!Number.isSafeInteger(value) || value <= 0 || value > parent) { + throw protectedError(StorageErrorCode.INVALID_ARGUMENT, "mount", undefined, { + permanent: true, + }); + } + return value; +} + +function restrictPermissions( + parent: ReadonlySet, + requested: Iterable | undefined, +): readonly StorageWorkspacePermission[] { + if (requested === undefined) return Object.freeze([...parent]); + const result: StorageWorkspacePermission[] = []; + for (const permission of requested) { + if (!parent.has(permission)) { + throw protectedError(StorageErrorCode.UNAUTHORIZED, "mount", undefined, { + permanent: true, + }); + } + result.push(permission); + } + return Object.freeze(result); +} + +function operationOptions( + options: StorageOperationOptions | undefined, + signal: AbortSignal | undefined, +): StorageOperationOptions | undefined { + if (options === undefined && signal === undefined) return undefined; + return { + ...(options?.retries === undefined ? {} : { retries: options.retries }), + ...(signal === undefined ? {} : { signal }), + }; +} + +function listOptions( + options: StorageWorkspaceListOptions | undefined, + signal: AbortSignal | undefined, +): StorageWorkspaceListOptions { + return { + ...(options?.cursor === undefined ? {} : { cursor: options.cursor }), + ...(options?.directory === undefined ? {} : { directory: options.directory }), + ...(options?.limit === undefined ? {} : { limit: options.limit }), + ...(options?.recursive === undefined ? {} : { recursive: options.recursive }), + ...operationOptions(options, signal), + }; +} + +function searchOptions( + options: StorageWorkspaceSearchOptions | undefined, + signal: AbortSignal | undefined, +): StorageWorkspaceSearchOptions { + return { + ...(options?.caseInsensitive === undefined ? {} : { caseInsensitive: options.caseInsensitive }), + ...(options?.cursor === undefined ? {} : { cursor: options.cursor }), + ...(options?.directory === undefined ? {} : { directory: options.directory }), + ...(options?.limit === undefined ? {} : { limit: options.limit }), + ...(options?.match === undefined ? {} : { match: options.match }), + ...operationOptions(options, signal), + }; +} + +function storageDeleteOptions( + options: StorageWorkspaceDeleteOptions, + signal: AbortSignal | undefined, +): StorageWorkspaceDeleteOptions { + const operation = operationOptions(options, signal); + if ("mode" in options && options.mode === "unconditional") { + return { ...operation, mode: "unconditional" }; + } + if (!("etag" in options)) { + throw new TypeError("Protected workspace delete options are invalid."); + } + return { ...operation, etag: options.etag }; +} + +function operationRuntime( + lifetimeSignal: AbortSignal | undefined, + options: StorageOperationOptions | undefined, + operation: string, + path: string | undefined, +): OperationRuntime { + if (options !== undefined && (typeof options !== "object" || options === null)) { + throw protectedError(StorageErrorCode.INVALID_ARGUMENT, operation, path, { + permanent: true, + }); + } + const callerSignal = options?.signal; + if (callerSignal !== undefined && !isAbortSignal(callerSignal)) { + throw protectedError(StorageErrorCode.INVALID_ARGUMENT, operation, path, { + permanent: true, + }); + } + const timeout = options?.timeout; + if ( + timeout !== undefined && + (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > MAX_OPERATION_TIMEOUT_MS) + ) { + throw protectedError(StorageErrorCode.INVALID_ARGUMENT, operation, path, { + permanent: true, + }); + } + const sources = [ + ...new Set( + [lifetimeSignal, callerSignal].filter( + (signal): signal is AbortSignal => signal !== undefined, + ), + ), + ]; + if (sources.length === 0 && timeout === undefined) { + return { close: () => undefined, signal: undefined, timedOut: () => false }; + } + const controller = new AbortController(); + let timeoutReached = false; + const listeners = new Map void>(); + for (const source of sources) { + const listener = (): void => controller.abort(source.reason); + listeners.set(source, listener); + source.addEventListener("abort", listener, { once: true }); + if (source.aborted) listener(); + } + const timer = + timeout === undefined + ? undefined + : setTimeout(() => { + timeoutReached = true; + controller.abort(new DOMException("The operation timed out.", "TimeoutError")); + }, timeout); + timer?.unref(); + return { + close: () => { + if (timer !== undefined) clearTimeout(timer); + for (const [source, listener] of listeners) { + source.removeEventListener("abort", listener); + } + }, + signal: controller.signal, + timedOut: () => timeoutReached, + }; +} + +async function raceWithAbort( + work: () => Promise, + signal: AbortSignal | undefined, +): Promise { + if (signal === undefined) return await work(); + if (signal.aborted) throw signal.reason; + return await new Promise((resolve, reject) => { + let settled = false; + const finish = (callback: () => void): void => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbort); + callback(); + }; + const onAbort = (): void => finish(() => reject(signal.reason)); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + const pending = Promise.resolve().then(async () => { + if (signal.aborted) throw signal.reason; + return await work(); + }); + void pending.then( + (value) => finish(() => resolve(value)), + (error: unknown) => finish(() => reject(error)), + ); + }); +} + +function frameContext(kind: "aad" | "key-context", values: readonly Uint8Array[]): Uint8Array { + const fields = [textEncoder.encode(CONTEXT_DOMAIN), textEncoder.encode(kind), ...values]; + let size = 0; + for (const field of fields) { + if (field.byteLength > 0xffff_ffff || size > Number.MAX_SAFE_INTEGER - 4 - field.byteLength) { + throw new TypeError("Protected workspace context is too large."); + } + size += 4 + field.byteLength; + } + const framed = new Uint8Array(size); + const view = new DataView(framed.buffer); + let offset = 0; + for (const field of fields) { + view.setUint32(offset, field.byteLength, false); + offset += 4; + framed.set(field, offset); + offset += field.byteLength; + } + return framed; +} + +function sanitizeError( + error: unknown, + operation: string, + path: string | undefined, + timedOut: boolean, + aborted: boolean, +): StorageError { + if (timedOut) { + return protectedError(StorageErrorCode.TIMEOUT, operation, path, { + aborted: true, + timedOut: true, + }); + } + if (aborted) { + return protectedError(StorageErrorCode.ABORTED, operation, path, { + aborted: true, + }); + } + if (isStorageError(error)) { + return protectedError(error.code, operation, path, { + aborted: error.aborted, + permanent: error.permanent, + timedOut: error.timedOut, + }); + } + if (isAbortLike(error)) { + return protectedError(StorageErrorCode.ABORTED, operation, path, { + aborted: true, + }); + } + return protectedError(StorageErrorCode.PROVIDER, operation, path, { + permanent: true, + }); +} + +function authenticationError(operation: string, path: string, error: unknown): StorageError { + if (isStorageError(error)) { + return protectedError(error.code, operation, path, { + aborted: error.aborted, + permanent: error.permanent, + timedOut: error.timedOut, + }); + } + if (isAbortLike(error)) { + return protectedError(StorageErrorCode.ABORTED, operation, path, { aborted: true }); + } + return protectedError(StorageErrorCode.PROVIDER, operation, path, { permanent: true }); +} + +function protectedError( + code: StorageErrorCodeValue, + operation: string, + path: string | undefined, + flags: { + readonly aborted?: boolean; + readonly permanent?: boolean; + readonly timedOut?: boolean; + } = {}, +): StorageError { + return new StorageError(`Protected workspace ${operation} failed.`, { + aborted: flags.aborted === true, + code, + ...(path === undefined ? {} : { key: path }), + operation, + permanent: flags.permanent === true, + timedOut: flags.timedOut === true, + }); +} + +function isAbortLike(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + try { + return "name" in error && error.name === "AbortError"; + } catch { + return false; + } +} + +function assertOptionalSignal(signal: AbortSignal | undefined): void { + if (signal !== undefined && !isAbortSignal(signal)) { + throw new TypeError("Protected workspace lifetime signal is invalid."); + } +} + +function isAbortSignal(value: unknown): value is AbortSignal { + if (typeof value !== "object" || value === null) return false; + try { + return ( + typeof Object.getOwnPropertyDescriptor(AbortSignal.prototype, "aborted")?.get?.call(value) === + "boolean" + ); + } catch { + return false; + } +} + +function isStableBytes(value: unknown): value is Uint8Array { + try { + if (!(value instanceof Uint8Array) || !ArrayBuffer.isView(value)) return false; + const prototype: unknown = Object.getPrototypeOf(value); + if (prototype !== Uint8Array.prototype && prototype !== Buffer.prototype) return false; + return !(value.buffer instanceof SharedArrayBuffer) && Number.isSafeInteger(value.byteLength); + } catch { + return false; + } +} + +function positiveInteger(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError(`${label} must be a positive safe integer.`); + } + return value; +} + +function boundedUtf8(value: string, maxBytes: number, label: string): Uint8Array { + if (typeof value !== "string" || value.length > maxBytes) { + throw new TypeError(`${label} exceeds its UTF-8 byte limit.`); + } + const encodedUpperBound = value.length * 3; + const detectionBound = maxBytes === Number.MAX_SAFE_INTEGER ? maxBytes : maxBytes + 1; + const target = new Uint8Array(Math.min(encodedUpperBound, detectionBound)); + const result = textEncoder.encodeInto(value, target); + if (result.read !== value.length || result.written > maxBytes) { + target.fill(0); + throw new TypeError(`${label} exceeds its UTF-8 byte limit.`); + } + const bytes = target.slice(0, result.written); + target.fill(0); + return bytes; +} + +function joinPath(directory: string, path: string): string { + return directory.length === 0 ? path : `${directory}/${path}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/storage-workspace/types.ts b/src/storage-workspace/types.ts new file mode 100644 index 0000000..5948423 --- /dev/null +++ b/src/storage-workspace/types.ts @@ -0,0 +1,93 @@ +import type { StorageOperationOptions } from "@nestm/storage/core"; +import type { + StorageWorkspace, + StorageWorkspaceFile, + StorageWorkspaceLimits, + StorageWorkspaceMountOptions, + StorageWorkspaceReadOptions, +} from "@nestm/storage/workspace"; + +/** Inputs authenticated by a workspace cipher for one protected value. */ +export interface AuthenticatedWorkspaceCipherContext { + readonly authenticatedData: Uint8Array; + readonly keyContext: Uint8Array; + readonly signal?: AbortSignal; +} + +/** + * Minimal authenticated-encryption boundary required by a protected workspace. + * + * Implementations must return canonical `nmc1` envelopes and must authenticate + * both context byte strings during decryption. Inputs and context buffers are + * borrowed for one call: implementations must stay within application-owned + * bounds, honor the supplied signal, and retain no buffer after settlement. + */ +export interface AuthenticatedWorkspaceCipher { + encryptBytes( + plaintext: Uint8Array, + context: AuthenticatedWorkspaceCipherContext, + ): Promise; + decryptBytes(envelope: string, context: AuthenticatedWorkspaceCipherContext): Promise; +} + +export interface ProtectedStorageWorkspaceByteFile extends StorageWorkspaceFile { + readonly bytes: Uint8Array; +} + +export type ProtectedStorageWorkspacePathSearch = "disabled" | "provider-visible"; + +export interface ProtectedStorageWorkspaceProtection { + readonly kind: "authenticated-encryption"; + readonly outerFormat: "nestm-protected-storage-workspace"; + readonly outerVersion: 1; + readonly cipherEnvelope: "nmc1"; + readonly metadata: "encrypted"; + readonly body: "encrypted"; + readonly pathBinding: "scope-policy-path-record-version-purpose"; + readonly pathSearch: ProtectedStorageWorkspacePathSearch; + readonly policyRevision: string; +} + +export interface ProtectedStorageWorkspace extends StorageWorkspace { + readonly protection: Readonly; + readBytes( + path: string, + options?: StorageWorkspaceReadOptions, + ): Promise; + mount(directory: string, options?: StorageWorkspaceMountOptions): ProtectedStorageWorkspace; +} + +export interface ProtectStorageWorkspaceOptions { + readonly storage: StorageWorkspace; + readonly cipher: AuthenticatedWorkspaceCipher; + /** Opaque deployment/tenant/workspace scope. It is copied and never exposed. */ + readonly scopeContext: string | Uint8Array; + /** Application-owned policy/key-domain revision authenticated with every value. */ + readonly policyRevision: string; + /** + * Logical limits exposed by the protected view. At initial composition, + * non-buffer limits must equal the backing workspace; maxReadBytes and + * maxWriteBytes may be lower. Derived mounts can narrow every limit. + */ + readonly limits: Readonly; + /** + * Physical bound for the complete encrypted outer object. It must cover the + * larger logical read/write ceiling and fit the backing read/write limits. + */ + readonly maxCiphertextBytes: number; + /** Registry-owned lifetime cancellation inherited by every derived mount. */ + readonly signal?: AbortSignal; + /** Explicitly allows cleartext path/query search at the storage provider. */ + readonly pathSearch?: ProtectedStorageWorkspacePathSearch; +} + +export interface CipherEngineWorkspaceCipherOptions { + /** Provider selected for new envelopes. Defaults to the engine provider. */ + readonly provider?: string; + /** Cipher selected for new envelopes. Defaults to the engine cipher. */ + readonly cipher?: string; + /** Providers admitted while reading, for explicit key-rotation windows. */ + readonly allowedProviders?: readonly string[]; +} + +export type ProtectedStorageOperationOptions = StorageOperationOptions; diff --git a/tsdown.config.ts b/tsdown.config.ts index 37082f9..0f9d13f 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ "src/index.ts", "src/core/index.ts", "src/files/index.ts", + "src/storage-workspace/index.ts", "src/fields/index.ts", "src/tenant/index.ts", "src/http/index.ts", @@ -27,6 +28,7 @@ export default defineConfig({ deps: { neverBundle: [ /^@nestjs\//, + /^@nestm\/storage(?:\/|$)/, /^@nestm\/tenant(?:\/|$)/, /^@aws-sdk\/client-kms(?:\/|$)/, /^@google-cloud\/kms(?:\/|$)/,