diff --git a/conformance/runner/reports/report.json b/conformance/runner/reports/report.json index 6fb409252..1309cd316 100644 --- a/conformance/runner/reports/report.json +++ b/conformance/runner/reports/report.json @@ -33835,4 +33835,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/docs/about/contributing.md b/docs/about/contributing.md index bb29d394a..25a931237 100644 --- a/docs/about/contributing.md +++ b/docs/about/contributing.md @@ -4,7 +4,7 @@ title: 'Contributing' kind: meta version: '2.0.0' last_updated: '2026-07-30' -last_verified: '2026-07-30' +last_verified: '2026-08-30' review_cadence_days: 30 status: stable tags: [about, contributing, development, community] diff --git a/docs/about/doc-agent.md b/docs/about/doc-agent.md index ab2312b72..92d5e578c 100644 --- a/docs/about/doc-agent.md +++ b/docs/about/doc-agent.md @@ -4,7 +4,7 @@ title: 'Documentation Maintenance' kind: meta version: '2.1.0' last_updated: '2026-07-30' -last_verified: '2026-07-30' +last_verified: '2026-08-30' review_cadence_days: 30 status: stable tags: [about, documentation, maintenance, automation] diff --git a/docs/infrastructure/message-box-server.md b/docs/infrastructure/message-box-server.md index 2b7fb3cb0..29c74950d 100644 --- a/docs/infrastructure/message-box-server.md +++ b/docs/infrastructure/message-box-server.md @@ -70,6 +70,7 @@ all limits, shared state, BRC-105 pricing, memory evidence, and scaling guidance | Variable | Required | Description | | ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------- | | NODE_ENV | No | `development`, `staging`, or `production` | +| BSV_NETWORK | No | `mainnet`, `testnet`, `ttn`, or `teratestnet` (default `mainnet`) | | PORT | No | HTTP/WebSocket port (default 8080; takes precedence) | | HTTP_PORT | No | Compatibility port fallback | | HOSTING_DOMAIN | No | Public domain for overlay advertisement (e.g., `http://localhost:8080`) | @@ -159,7 +160,7 @@ Migrations tracked in `src/migrations/`: ## Health checks -- `GET /health` reports process liveness without authentication. +- `GET /health` and `GET /healthz` report process liveness without authentication. - `GET /ready` verifies database connectivity and returns a non-sensitive 503 response while dependencies are unavailable. - Test an authenticated WebSocket handshake separately when live transport is diff --git a/docs/infrastructure/service-edge-security.md b/docs/infrastructure/service-edge-security.md index fb5b333da..a79bdc54b 100644 --- a/docs/infrastructure/service-edge-security.md +++ b/docs/infrastructure/service-edge-security.md @@ -4,7 +4,7 @@ title: 'Public Service Edge Security' kind: reference version: '1.0.0' last_updated: '2026-07-30' -last_verified: '2026-07-30' +last_verified: '2026-08-30' review_cadence_days: 30 status: stable tags: [infrastructure, security, cors, rate-limits, authentication, operations] diff --git a/docs/infrastructure/uhrp-server-basic.md b/docs/infrastructure/uhrp-server-basic.md index 8213186c8..819a5566f 100644 --- a/docs/infrastructure/uhrp-server-basic.md +++ b/docs/infrastructure/uhrp-server-basic.md @@ -40,7 +40,7 @@ Clients PUT files with authentication, retrieve files via public GET, and query | Type | Requirement | | ----------------- | --------------------------------------------------------------------------------------------------- | | Database | None; filesystem-based storage | -| External services | Wallet Storage (WALLET_STORAGE_URL), ARC (optional for payment transactions) | +| External services | Wallet Storage (WALLET_STORAGE_URL) | | ts-stack packages | @bsv/sdk, @bsv/auth-express-middleware, @bsv/payment-express-middleware, @bsv/wallet-toolbox-client | ## HTTP endpoints @@ -65,7 +65,7 @@ None. | -------------------------- | -------- | --------------------------------------------------------------------------------------------- | | PRICE_PER_GB_MO | No | Monthly storage price per GB (e.g., `0.03`) | | HOSTING_DOMAIN | No | Public domain for server advertisement (e.g., `localhost:8080` or `https://uhrp.example.com`) | -| BSV_NETWORK | No | Target blockchain network (e.g., `mainnet` or `testnet`) | +| BSV_NETWORK | No | `mainnet`, `testnet`, `ttn`, or `teratestnet` (default `mainnet`) | | WALLET_STORAGE_URL | No | Wallet storage endpoint for key derivation (e.g., `https://store-us-1.bsvb.tech`) | | SERVER_PRIVATE_KEY | Yes | 256-bit hex private key for server identity | | HTTP_PORT | No | Express server port (default: 8080) | diff --git a/docs/infrastructure/uhrp-server-cloud-bucket.md b/docs/infrastructure/uhrp-server-cloud-bucket.md index 3a7b9fa22..1fffa87ee 100644 --- a/docs/infrastructure/uhrp-server-cloud-bucket.md +++ b/docs/infrastructure/uhrp-server-cloud-bucket.md @@ -25,7 +25,8 @@ workflows backed by Google Cloud Storage. Static object retrieval is public; upload, list, find, and renewal require BRC-103 identity. A separate administrative advertisement endpoint uses a strong Bearer token. -Clients upload files with authentication, retrieve files via public GET, and server continuously advertises hosting capability. +Clients request authenticated uploads, retrieve files via public GET, and use +the bucket notifier to trigger authenticated hosting advertisements. ## When to deploy this @@ -69,16 +70,13 @@ None; HTTP-only with background advertising worker. | NODE_ENV | No | `development`, `staging`, or `production` | | SERVER_PRIVATE_KEY | Yes | 256-bit hex private key for server identity | | HOSTING_DOMAIN | No | Public HTTPS domain for advertising (e.g., `https://uhrp-storage.example.com`) | -| BSV_NETWORK | No | Target blockchain network (`main`, `test`, or `regtest`) | +| BSV_NETWORK | No | `mainnet`, `testnet`, `ttn`, or `teratestnet` (default `mainnet`) | | WALLET_STORAGE_URL | No | Wallet storage endpoint (e.g., `https://store-us-1.bsvb.tech`) | | PRICE_PER_GB_MO | No | Monthly storage price per GB for billing | -| ENABLE_PAYMENT_MIDDLEWARE | No | Set to `'true'` to require payment for uploads | -| GOOGLE_CLOUD_PROJECT | No | GCP project ID (auto-detected from service account if available) | -| GOOGLE_CLOUD_BUCKET | Yes | Cloud Storage bucket name (e.g., `uhrp-storage-prod`) | -| GOOGLE_APPLICATION_CREDENTIALS | No | Path to service account JSON key (for local/Cloud Run auth) | -| ARC_API_KEY | No | ARC API key for transaction broadcasting (advertising) | -| ADVERTISE_INTERVAL_MS | No | Interval for re-advertising to overlay (default: 3600000ms = 1 hour) | -| BUGSNAG_API_KEY | No | Bugsnag error reporting API key (optional) | +| MIN_HOSTING_MINUTES | No | Minimum requested retention period (default 180 minutes) | +| GCP_PROJECT_ID | Yes* | GCP project used for production signed upload URLs | +| GCP_BUCKET_NAME | Yes | Cloud Storage bucket name (e.g., `uhrp-storage-prod`) | +| GCP_STORAGE_CREDS | Yes* | JSON credentials used for production signed upload URLs; provide through a secret | | ADMIN_TOKEN | Yes | At least 32 random characters for `/advertise` Bearer auth | | UHRP_CORS_MODE | No | `public` (default), `allowlist`, or `disabled` | | UHRP_CORS_ALLOWED_ORIGINS | No | Exact comma-separated origins in allowlist mode | @@ -86,6 +84,9 @@ None; HTTP-only with background advertising worker. | UHRP_JSON_MAX_BODY_BYTES | No | JSON body ceiling (default 262144) | | TRUST_PROXY_HOPS | No | Exact trusted proxy hop count, 0 through 10 | +`GCP_PROJECT_ID` and `GCP_STORAGE_CREDS` are required by the production +signed-upload path; the development path returns a local placeholder URL. + See [Public Service Edge Security](service-edge-security.md#uhrp-cloud-bucket-server) for full edge controls. @@ -118,7 +119,7 @@ gcloud run deploy uhrp-storage \ --image uhrp-storage:latest \ --platform managed \ --region us-central1 \ - --set-env-vars SERVER_PRIVATE_KEY=,GOOGLE_CLOUD_BUCKET=uhrp-storage-prod,ENABLE_PAYMENT_MIDDLEWARE=true + --set-env-vars SERVER_PRIVATE_KEY=,GCP_PROJECT_ID=,GCP_BUCKET_NAME=uhrp-storage-prod,ADMIN_TOKEN=<32+-character-token> # Or deploy with docker-compose (local testing only) docker compose up -d @@ -147,17 +148,18 @@ No database migrations. Google Cloud Storage is the durable source of truth. - UHRP clients upload/retrieve files using SERVER_PRIVATE_KEY and HOSTING_DOMAIN - Wallet Storage derives keys, validates payments, manages user accounts -- Background worker advertises UHRP host via SHIP overlay protocol using ARC broadcaster -- Optional Cloud SQL metadata database for query optimization -- Bugsnag integration for production error tracking and monitoring +- The bucket notifier calls the token-protected `/advertise` route, which + publishes the UHRP advertisement through the SDK SHIP broadcaster ## Common pitfalls - GCP credentials: GOOGLE_APPLICATION_CREDENTIALS must point to valid service account JSON; Cloud Run uses default service account if not set - Storage bucket policy: Ensure bucket exists and service account has storage.objects.create/get/delete permissions - Cost management: Monitor storage usage and pricing; use Cloud Storage lifecycle policies for archival -- Payment enforcement: ENABLE_PAYMENT_MIDDLEWARE requires ARC_API_KEY and WALLET_STORAGE_URL; uploads fail if not configured -- Advertising loop: ADVERTISE_INTERVAL_MS should balance frequent updates vs transaction costs; 1 hour is conservative default +- Signed uploads: `GCP_PROJECT_ID`, `GCP_BUCKET_NAME`, and valid JSON in + `GCP_STORAGE_CREDS` must agree; malformed credentials fail URL creation +- Advertising: `ADMIN_TOKEN` must match the bucket notifier and contain at + least 32 characters - Cloud Run and application request timeouts default to 60 seconds; use direct cloud upload workflows for large objects rather than unbounded application buffering - Graceful shutdown: Cloud Run sends SIGTERM; ensure all writes complete before exit (transaction broadcasts, metadata flushes) diff --git a/docs/packages/wallet/ecpm-permission-module.md b/docs/packages/wallet/ecpm-permission-module.md new file mode 100644 index 000000000..9eca197c7 --- /dev/null +++ b/docs/packages/wallet/ecpm-permission-module.md @@ -0,0 +1,106 @@ +--- +id: ecpm-permission-module +title: '@bsv/ecpm-permission-module' +kind: package +domain: wallet +npm: '@bsv/ecpm-permission-module' +version: '0.1.0' +last_updated: '2026-08-30' +last_verified: '2026-08-30' +review_cadence_days: 30 +status: experimental +tags: ['permissions', 'brc98', 'ecpm', 'cryptography'] +repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ecpm-permission-module' +--- + +# @bsv/ecpm-permission-module + +`@bsv/ecpm-permission-module` is the reference `p ecpm` semantic module for +BRC-100 wallet hosts. It applies or removes a wallet-derived scalar from an +arbitrary validated secp256k1 point while retaining the standard +`getPublicKey` request and response shapes. + +Use this package when a protocol needs commutative point masking or another +point operation whose base is supplied by the application. Pure BRC-43 can +name a counterparty while deriving the scalar, but ordinary `getPublicKey` +still returns that scalar times the fixed generator; it cannot select the +caller's point as the multiplication base. + +## Install + +```bash +npm install @bsv/ecpm-permission-module @bsv/wallet-toolbox-client @bsv/sdk +``` + +## Protocol + +Call the existing `getPublicKey` method with this protocol name inside the +normal `[securityLevel, protocolName]` tuple: + +```text +p ecpm +``` + +The module reads `keyID`, `counterparty`, `privileged`, `privilegedReason`, and +`seekPermission` from their existing fields. It derives the scalar under +`p ecpm `; the operation and point are deliberately omitted +so `remove` uses the inverse of the exact scalar selected by `apply`. + +```typescript +const masked = await wallet.getPublicKey({ + protocolID: [2, `p ecpm apply ${pointHex} mental poker deal`], + keyID: 'deck mask', + counterparty: 'self' +}) + +const restored = await wallet.getPublicKey({ + protocolID: [2, `p ecpm remove ${masked.publicKey} mental poker deal`], + keyID: 'deck mask', + counterparty: 'self' +}) +``` + +## Wallet installation + +```typescript +import { createEcpmModule } from '@bsv/ecpm-permission-module' +import { WalletPermissionsManager } from '@bsv/wallet-toolbox-client' + +const ecpm = createEcpmModule({ + keyDeriver: setup.keyDeriver, + authorize: request => showTrustedWalletPrompt(request), + privilegedKeyDeriver: reason => acquirePrivilegedKeyDeriver(reason) +}) + +const wallet = new WalletPermissionsManager(setup.wallet, adminOriginator, { + permissionModules: { ecpm } +}) +``` + +The privileged provider is optional. If an application requests +`privileged: true`, the module authorizes the supplied reason before asking the +host for a privileged deriver and fails closed when no provider is available. + +## Security and permissions + +- Only `getPublicKey` is accepted under `p ecpm`; signing, HMAC, and encryption + calls cannot reuse the ECPM-derived scalar. +- Identity-key and `forSelf` modes are rejected. +- Input points and public-key counterparties must be canonical lowercase, + compressed, finite secp256k1 points. +- Security level 0 ordinary calls do not prompt. Levels 1 and 2 require the + authorization callback; level 2 grants are scoped to the counterparty. +- Every privileged call requires authorization. Cached and concurrent grants + are scoped to the exact approved `privilegedReason`, so a different reason + cannot reuse the approval. `seekPermission: false` fails unless an applicable + grant is already cached. +- The application receives only `{ publicKey }`, never the derived scalar or + either key-derivation provider. + +## Module interface + +Wallet Toolbox exposes the optional +`PermissionsModule.handleRequest(request, next)` semantic hook. A module can +return the standard BRC-100 result directly, as ECPM does, or call `next` at +most once. Existing `onRequest`/`onResponse` transformation modules remain +compatible. diff --git a/docs/packages/wallet/index.md b/docs/packages/wallet/index.md index eff6e5283..7aeba82a8 100644 --- a/docs/packages/wallet/index.md +++ b/docs/packages/wallet/index.md @@ -26,6 +26,7 @@ The wallet domain builds on top of [@bsv/sdk](../sdk/bsv-sdk.md). If you only ne | [@bsv/wallet-toolbox-mobile](./wallet-toolbox-mobile.md) | React Native/mobile-safe wallet and remote storage distribution | | [@bsv/btms](./btms.md) | UTXO-based token issuance, transfer, burning, and ownership proof validation | | [@bsv/btms-permission-module](./btms-permission-module.md) | Framework-agnostic BRC-98/99 permission hooks for BTMS token spending with custom UI callback | +| [@bsv/ecpm-permission-module](./ecpm-permission-module.md) | BRC-98 semantic module for applying and removing wallet-derived scalars from secp256k1 points | | [@bsv/wallet-relay](./wallet-relay.md) | Mobile-to-desktop wallet pairing via QR codes and encrypted WebSocket relay with React components | ## Common Use Cases @@ -54,6 +55,13 @@ Use [@bsv/wallet-relay](./wallet-relay.md) for QR pairing. Desktop shows QR, mob Use [@bsv/btms-permission-module](./btms-permission-module.md) with your custom permission handler (modal, alert, web component, etc.). +### I need wallet-native point multiplication + +Use [@bsv/ecpm-permission-module](./ecpm-permission-module.md) to install the +`p ecpm` scheme. It reuses `getPublicKey`, so applications can apply or remove +a BRC-42/43-derived scalar from a named point without extending the BRC-100 +wallet interface or exposing the scalar. + ## Key Concepts - **BRC-100 Wallet Interface** — Standard interface implemented by all wallet packages. Apps can work with any wallet (desktop, mobile, hardware) without code changes. @@ -66,6 +74,7 @@ Use [@bsv/btms-permission-module](./btms-permission-module.md) with your custom - **Ownership Proof** — Cryptographic proof of token ownership without revealing private key. Used for collateral, escrow, access control. - **Relay Session** — Encrypted tunnel between desktop and mobile wallet. QR encodes relay URL + session ID; mobile scans and establishes WebSocket connection. - **Permission Module** — BRC-98/99 hooks that intercept special operations (token spend, burn) and prompt user via custom callback. +- **Semantic Permission Module** — A BRC-98 module that owns a P-scheme's meaning and returns a conforming result without requiring the underlying BRC-100 method to retain its ordinary behavior. ## Architecture Overview @@ -80,6 +89,7 @@ Use [@bsv/btms-permission-module](./btms-permission-module.md) with your custom | **wallet-toolbox-mobile** | Build tooling | — | Remote | | **btms** | ✓ | ✓ | ✓ | | **btms-permission-module** | ✓ | ✓ | ✓ | +| **ecpm-permission-module** | ✓ | ✓ | Host-dependent | | **wallet-relay** | Server | React components | Supported via relay | ## When to Use Each Package diff --git a/docs/packages/wallet/wallet-toolbox-client.md b/docs/packages/wallet/wallet-toolbox-client.md index e722ff9fc..8e60bf46a 100644 --- a/docs/packages/wallet/wallet-toolbox-client.md +++ b/docs/packages/wallet/wallet-toolbox-client.md @@ -3,9 +3,9 @@ id: pkg-wallet-toolbox-client title: '@bsv/wallet-toolbox-client' kind: package domain: wallet -version: '2.10.4' -last_updated: '2026-08-26' -last_verified: '2026-08-26' +version: '2.11.0' +last_updated: '2026-08-30' +last_verified: '2026-08-30' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/wallet-toolbox-client' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client' diff --git a/docs/packages/wallet/wallet-toolbox-mobile.md b/docs/packages/wallet/wallet-toolbox-mobile.md index 214c027d4..db49aa450 100644 --- a/docs/packages/wallet/wallet-toolbox-mobile.md +++ b/docs/packages/wallet/wallet-toolbox-mobile.md @@ -3,9 +3,9 @@ id: pkg-wallet-toolbox-mobile title: '@bsv/wallet-toolbox-mobile' kind: package domain: wallet -version: '2.10.4' -last_updated: '2026-08-26' -last_verified: '2026-08-26' +version: '2.11.0' +last_updated: '2026-08-30' +last_verified: '2026-08-30' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/wallet-toolbox-mobile' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile' diff --git a/docs/packages/wallet/wallet-toolbox.md b/docs/packages/wallet/wallet-toolbox.md index 74e0bed2b..4fe6b9dca 100644 --- a/docs/packages/wallet/wallet-toolbox.md +++ b/docs/packages/wallet/wallet-toolbox.md @@ -4,9 +4,9 @@ title: '@bsv/wallet-toolbox' kind: package domain: wallet npm: '@bsv/wallet-toolbox' -version: '2.10.4' -last_updated: '2026-08-26' -last_verified: '2026-08-26' +version: '2.11.0' +last_updated: '2026-08-30' +last_verified: '2026-08-30' review_cadence_days: 30 status: stable tags: ['wallet', 'brc100'] @@ -131,6 +131,18 @@ console.log(publicKey) `setup.wallet` is the BRC-100 wallet. The surrounding `setup` object exposes the constructed `rootKey`, `identityKey`, `keyDeriver`, `storage`, `services`, and `monitor` so wallet builders can inspect or replace pieces while developing. +## Permission modules + +`WalletPermissionsManager` registers BRC-98/99/111 modules by the scheme after +the `p` prefix. Existing modules can transform calls with `onRequest` and +`onResponse`. A semantic module can instead implement +`handleRequest(request, next)` and return the conforming BRC-100 result itself; +if it needs the underlying wallet operation, `next` is guarded to one call. + +The separate [@bsv/ecpm-permission-module](./ecpm-permission-module.md) uses +this hook to implement point multiplication under `p ecpm` while keeping +`getPublicKey` as the public wallet method. + ## Action Flow When every input can be signed by the wallet, `createAction` can return a completed action: diff --git a/docs/reference/ci-performance.md b/docs/reference/ci-performance.md index 676258af2..373f87ade 100644 --- a/docs/reference/ci-performance.md +++ b/docs/reference/ci-performance.md @@ -4,7 +4,7 @@ title: 'CI Performance Governance' kind: reference version: '1.1.0' last_updated: '2026-07-31' -last_verified: '2026-07-31' +last_verified: '2026-08-30' review_cadence_days: 30 status: stable tags: [reference, ci, performance, github-actions] diff --git a/docs/reference/index.md b/docs/reference/index.md index 5afdbd330..5cff88055 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -4,7 +4,7 @@ title: 'Reference' kind: meta version: 'n/a' last_updated: '2026-07-29' -last_verified: '2026-07-29' +last_verified: '2026-08-30' review_cadence_days: 30 status: stable tags: [reference, api, index] diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index cebeddc95..7e95b377d 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -12,7 +12,7 @@ tags: [reference, packages, api, declarations, migrations, release-notes] # Package API, Declarations, and Migration Ledger -This page is generated from all 32 public manifests, package documentation, and +This page is generated from all 33 public manifests, package documentation, and `governance/package-release-notes.json`. It records source candidates without publishing them. CI rejects a version change unless its release classification, summary, and migration guidance are updated at the same time. @@ -37,6 +37,7 @@ and clean-consumer tests remain the executable type authority. | `@bsv/chirp` | `0.0.0` | `0.1.1` | minor | [API and usage](../packages/network/chirp.md) | No consumer migration is required; this is the first release of a new additive package. Existing @bsv/sdk StorageUploader, StorageDownloader, StorageUtils, UHRP identifiers, overlays, and server routes remain unchanged. BRC-167 remains authoritative if the implementation and standard differ. | | `@bsv/did` | `0.2.1` | `0.2.5` | patch | [API and usage](../packages/helpers/did.md) | No consumer migration is required; DID APIs, encodings, credential behavior, and supported import forms are unchanged. | | `@bsv/did-client` | `1.2.1` | `1.3.1` | minor | [API and usage](../packages/helpers/did-client.md) | Existing local, mainnet, and testnet behavior is unchanged. TTN consumers select networkPreset teratestnet and use @bsv/sdk 2.4 or later. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | +| `@bsv/ecpm-permission-module` | `0.0.0` | `0.1.0` | minor | [API and usage](../packages/wallet/ecpm-permission-module.md) | No existing consumer migration is required; this is the first release. Wallet hosts register the module under the ecpm scheme and supply their ordinary key deriver, authorization handler, and optional privileged key provider. | | `@bsv/fund-wallet` | `1.4.1` | `1.4.4` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No consumer migration is required; wallet funding APIs and transaction behavior are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/gasp` | `1.3.1` | `1.3.6` | patch | [API and usage](../packages/overlays/gasp.md) | No consumer migration is required; existing constructor calls, imports, synchronization behavior, and wire semantics are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/message-box-client` | `2.4.0` | `2.4.2` | patch | [API and usage](../packages/messaging/message-box-client.md) | No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. | @@ -53,9 +54,9 @@ and clean-consumer tests remain the executable type authority. | `@bsv/verifast` | `0.3.0` | `0.3.5` | patch | [API and usage](../packages/sdk/verifast.md) | No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged. Keep THIRD_PARTY_NOTICES.md and LICENSES/ with every JavaScript and WebAssembly distribution. | | `@bsv/wallet-helper` | `0.1.1` | `0.1.7` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No consumer migration is required; fluent builder APIs and transaction semantics are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/wallet-relay` | `0.2.2` | `0.3.6` | minor | [API and usage](../packages/wallet/wallet-relay.md) | No wallet RPC migration is required; upgrade to @bsv/sdk 2.4.1 or later. Existing relay sessions and number arrays remain valid, and host applications continue to provide their matching Express runtime and type graph. | -| `@bsv/wallet-toolbox` | `2.10.0` | `2.10.4` | patch | [API and usage](../packages/wallet/wallet-toolbox.md) | No runtime consumer migration is required. Canonical AtomicBEEF, number-array behavior, and pagination contracts are unchanged; upgrade to @bsv/sdk 2.4.2 or later. Documentation users should use docs/storage.md instead of the removed JSight export. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. | -| `@bsv/wallet-toolbox-client` | `2.10.0` | `2.10.4` | patch | [API and usage](../packages/wallet/wallet-toolbox-client.md) | No consumer migration is required. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. | -| `@bsv/wallet-toolbox-mobile` | `2.10.0` | `2.10.4` | patch | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | No consumer migration is required. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. | +| `@bsv/wallet-toolbox` | `2.10.4` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Existing permission modules require no changes because onRequest and onResponse remain supported. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Upgrade to @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing. | +| `@bsv/wallet-toolbox-client` | `2.10.4` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Existing permission modules require no changes. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing. | +| `@bsv/wallet-toolbox-mobile` | `2.10.4` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | Existing permission modules require no changes. Semantic modules may add handleRequest; mobile hosts can register compatible semantic modules without changing the Wallet interface. Use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing. | | `create-bsv-app` | `1.0.2` | `1.1.1` | minor | [API and usage](../packages/helpers/create-bsv-app.md) | Existing mainnet and testnet scaffolds are unchanged. New TTN projects pass --network ttn or select TerraTestNet in the configurator. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | `none` means the source manifest matches the recorded npm baseline. Any other @@ -209,6 +210,17 @@ CLI entry points: `{"chirp":"./dist/cli.js"}`. | `./*.ts` | `./dist/src/*.js`
`./dist/src/*.cjs` | `./dist/src/*.d.ts`
`./dist/src/*.d.cts` | | `./package.json` | `./package.json` | — | +## @bsv/ecpm-permission-module + +- Package documentation: [docs/packages/wallet/ecpm-permission-module.md](../packages/wallet/ecpm-permission-module.md) +- Source: [packages/wallet/ecpm-permission-module](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ecpm-permission-module) +- Release note: Introduces the BRC-98 p ecpm semantic module, applying or removing a wallet-derived scalar from validated secp256k1 points through the existing BRC-100 getPublicKey surface, including reason-scoped privileged authorization and privileged-key-provider hooks. +- Migration: No existing consumer migration is required; this is the first release. Wallet hosts register the module under the ecpm scheme and supply their ordinary key deriver, authorization handler, and optional privileged key provider. + +| Public subpath | Runtime target(s) | Declaration target(s) | +| -------------- | ------------------ | --------------------- | +| `.` | `./dist/index.mjs` | `./dist/index.d.mts` | + ## @bsv/fund-wallet - Package documentation: [docs/packages/helpers/fund-wallet.md](../packages/helpers/fund-wallet.md) @@ -493,8 +505,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Retains BRC-95 and BRC-100 compatibility and stable bounded pagination, removes an obsolete exported JSight application bundle that lacked its required third-party license companion, preserves the package's earlier Open BSV grant in the distribution notice archive, and standardizes first-party author metadata on the current BSV Association name. -- Migration: No runtime consumer migration is required. Canonical AtomicBEEF, number-array behavior, and pagination contracts are unchanged; upgrade to @bsv/sdk 2.4.2 or later. Documentation users should use docs/storage.md instead of the removed JSight export. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. +- Release note: Adds the optional semantic handleRequest hook to BRC-98/99/111 permission modules while retaining the existing transformation hooks, BRC-95/BRC-100 compatibility, and stable bounded pagination. It also removes the obsolete JSight application bundle and preserves the package's earlier Open BSV grant in the distribution notice archive. +- Migration: Existing permission modules require no changes because onRequest and onResponse remain supported. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Upgrade to @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -507,8 +519,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Carries the browser Wallet Toolbox internalization and BRC-100 compatibility fixes, stable IndexedDB totals, preserves earlier Open BSV grants in the distribution notice archive, and standardizes first-party author metadata on the current BSV Association name. -- Migration: No consumer migration is required. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. +- Release note: Exports the optional semantic handleRequest permission-module hook for browser and ESM wallet hosts while retaining transformation modules, BRC-100 wire compatibility, stable IndexedDB totals, and the current browser Wallet Toolbox compatibility fixes. It preserves earlier Open BSV grants in the distribution notice archive. +- Migration: Existing permission modules require no changes. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -519,8 +531,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Carries the mobile Wallet Toolbox internalization and BRC-100 compatibility fixes, preserves earlier Open BSV grants in the distribution notice archive, and standardizes first-party author metadata on the current BSV Association name. -- Migration: No consumer migration is required. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. +- Release note: Exports the optional semantic handleRequest permission-module hook for React Native wallet hosts while retaining transformation modules, BRC-100 wire compatibility, and the current mobile Wallet Toolbox compatibility fixes. It preserves earlier Open BSV grants in the distribution notice archive. +- Migration: Existing permission modules require no changes. Semantic modules may add handleRequest; mobile hosts can register compatible semantic modules without changing the Wallet interface. Use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | diff --git a/docs/reference/release-operations.md b/docs/reference/release-operations.md index 824cef45e..eb81385ed 100644 --- a/docs/reference/release-operations.md +++ b/docs/reference/release-operations.md @@ -4,7 +4,7 @@ title: 'Release and Operations Guide' kind: reference version: '1.1.2' last_updated: '2026-07-30' -last_verified: '2026-07-30' +last_verified: '2026-08-30' review_cadence_days: 30 status: stable tags: [reference, releases, operations, rollback, npm, containers] diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index 9d917980f..f5f092f55 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -3,8 +3,8 @@ id: stack-facts title: 'Generated Stack Facts' kind: reference version: '1.0.0' -last_updated: '2026-07-30' -last_verified: '2026-07-30' +last_updated: '2026-08-30' +last_verified: '2026-08-30' review_cadence_days: 30 status: stable tags: [reference, packages, versions, runtimes, conformance, generated] @@ -31,7 +31,7 @@ Node consumers; they do not require a browser or mobile device to provide Node A ## Public package manifest -The release graph currently contains **32 public packages**. Versions +The release graph currently contains **33 public packages**. Versions below are source-manifest versions; registry publication is a separate, explicitly authorized release action. @@ -65,10 +65,11 @@ authorized release action. | sdk | `@bsv/verifast` | `0.3.5` | wasm-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global, wasm-worker | browser, node, umd, wasm, worker | `>=22` | [packages/verifast](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/verifast) | | wallet | `@bsv/btms` | `1.2.2` | node-library | node-cjs, node-esm | node | `>=22` | [packages/wallet/btms](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms) | | wallet | `@bsv/btms-permission-module` | `1.1.4` | node-library | node-esm | node | `>=22` | [packages/wallet/btms-permission-module](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms-permission-module) | +| wallet | `@bsv/ecpm-permission-module` | `0.1.0` | browser-library | browser-bundler, browser-esm, node-esm | browser, node | `>=22` | [packages/wallet/ecpm-permission-module](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ecpm-permission-module) | | wallet | `@bsv/wallet-relay` | `0.3.6` | cli-library | browser-bundler, browser-esm, cli, node-cjs, node-esm | browser, node | `>=22` | [packages/wallet/ts-wallet-relay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ts-wallet-relay) | -| wallet | `@bsv/wallet-toolbox` | `2.10.4` | node-library | node-cjs | node | `>=22` | [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) | -| wallet | `@bsv/wallet-toolbox-client` | `2.10.4` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) | -| wallet | `@bsv/wallet-toolbox-mobile` | `2.10.4` | react-native-library | react-native-metro | react-native | `>=22` | [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) | +| wallet | `@bsv/wallet-toolbox` | `2.11.0` | node-library | node-cjs | node | `>=22` | [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) | +| wallet | `@bsv/wallet-toolbox-client` | `2.11.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) | +| wallet | `@bsv/wallet-toolbox-mobile` | `2.11.0` | react-native-library | react-native-metro | react-native | `>=22` | [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) | ## Standalone infrastructure manifests @@ -89,9 +90,9 @@ the separately released and verified image digest. | Metric | Count | | --- | --- | -| Governed projects | 39 | -| Package-area projects | 35 | -| Public npm packages | 32 | +| Governed projects | 40 | +| Package-area projects | 36 | +| Public npm packages | 33 | | Private package-area projects | 3 | | Standalone infrastructure projects | 7 | @@ -126,7 +127,7 @@ targets have been completed. | Metric | Current value | Authority | | --- | --- | --- | -| Projects with a test:coverage script | 34 | current package manifests | +| Projects with a test:coverage script | 35 | current package manifests | | Aggregate line coverage | 66.97% | https://app.codecov.io/gh/BSV-blockchain/ts-stack | | Reported source files | 543 | https://app.codecov.io/gh/BSV-blockchain/ts-stack | | Reported lines (hit / missed / partial) | 30981 / 11619 / 3659 | https://app.codecov.io/gh/BSV-blockchain/ts-stack | diff --git a/docs/reference/typescript-toolchain.md b/docs/reference/typescript-toolchain.md index 957d90cef..7ae86f566 100644 --- a/docs/reference/typescript-toolchain.md +++ b/docs/reference/typescript-toolchain.md @@ -3,8 +3,8 @@ id: typescript-toolchain title: 'TypeScript Compiler and Tooling Boundary' kind: reference version: '1.1.0' -last_updated: '2026-07-29' -last_verified: '2026-07-29' +last_updated: '2026-08-30' +last_verified: '2026-08-30' review_cadence_days: 30 status: stable tags: [reference, typescript, compiler, testing, toolchain] @@ -67,8 +67,8 @@ requires: - the independently locked TypeScript 5.9.3 codegen boundary; and - repository-wide fan-out when the toolchain policy or checker changes. -The current governed inventory is 43 native compiler profiles plus one isolated -codegen API profile. It also discovers all 121 tracked `tsconfig` files, resolves +The current governed inventory is 46 native compiler profiles plus one isolated +codegen API profile. It also discovers all 128 tracked `tsconfig` files, resolves their complete `extends` chains, rejects missing or circular configurations, and requires every project to use one of nine approved runtime profiles. Seven deployable services have self-contained `tsconfig` files because their npm and @@ -135,6 +135,6 @@ later stable API becomes available. Remove `@typescript/typescript6` only after every API consumer has a supported native replacement and the full matrix passes without it. -This migration changes development manifests in all 30 public packages. Record +This migration changes development manifests in all 33 public packages. Record them for the final coordinated patch release, but do not publish from a workstation. diff --git a/docs/specs/air-gap-optical.md b/docs/specs/air-gap-optical.md index f7daa1dc4..0033199f4 100644 --- a/docs/specs/air-gap-optical.md +++ b/docs/specs/air-gap-optical.md @@ -4,7 +4,7 @@ title: Air-Gap Optical Transport (BRC-141) kind: spec version: '1.0.0' last_updated: '2026-07-30' -last_verified: '2026-07-30' +last_verified: '2026-08-30' review_cadence_days: 30 status: experimental tags: ['spec', 'transport', 'air-gap', 'qr', 'optical'] diff --git a/governance/browser-artifact-policy.json b/governance/browser-artifact-policy.json index 4320fa7c3..29ff99b48 100644 --- a/governance/browser-artifact-policy.json +++ b/governance/browser-artifact-policy.json @@ -103,6 +103,13 @@ "entry": "./client", "splittingDisposition": "The client subpath excludes Express and the server-only QR adapter; qrcode is dynamically imported only by the server session manager." }, + { + "name": "@bsv/ecpm-permission-module", + "path": "packages/wallet/ecpm-permission-module", + "budget": "packages/wallet/ecpm-permission-module/browser-budget.json", + "entry": ".", + "splittingDisposition": "The ESM entry is a single semantic wallet module; it imports only the public secp256k1 primitives from @bsv/sdk, while wallet-toolbox interfaces are type-only and no host, server, UI, or privileged-key adapter is bundled." + }, { "name": "@bsv/wallet-toolbox-client", "path": "packages/wallet/wallet-toolbox/client", diff --git a/governance/dependency-release-policy.json b/governance/dependency-release-policy.json index a42663784..303673d96 100644 --- a/governance/dependency-release-policy.json +++ b/governance/dependency-release-policy.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "lastReviewed": "2026-08-13", + "lastReviewed": "2026-08-30", "owner": "ts-stack-maintainers", "routineUpdates": { "dependabotConfig": ".github/dependabot.yml", @@ -144,10 +144,10 @@ "closeImplementationWaveAfterPublication": false }, "overrideRemovalReview": { - "reviewedAt": "2026-08-13", - "method": "Rechecked all retained root and standalone overrides against their current natural dependency graphs, refreshed the root lock against the current advisory database, and reproduced the updated nanoid and image-size findings before selecting the narrowest compatible override and committed parser patch.", + "reviewedAt": "2026-08-30", + "method": "Rechecked retained root and standalone overrides against the frozen locks, current package metadata, and the current advisory audit. Verified that typed-rest-client 2.3.1 still pins qs 6.15.1, Google and OpenTelemetry paths still request gaxios 6.x or 7.1.3, and teeny-request still permits uuid 9.x.", "retainedCount": 20, - "result": "Every remaining override still prevents a reproduced vulnerable transitive version or preserves the isolated reproducible codegen closure. The nanoid override selects 3.3.18, the first 3.x release patched after GHSA-2v37-7h3g-55p8 was expanded on 2026-08-13. No override can be removed safely in this wave.", + "result": "All 20 retained overrides still prevent a reproduced vulnerable transitive version or preserve an isolated reproducible toolchain closure. The expired qs, gaxios, and uuid entries remain necessary; the frozen graph resolves their reviewed patched versions and the high-severity audit reports only the two governed image-size advisories.", "nextReview": "Rehearse removal monthly and immediately after upstream Google client, Jest/minimatch, typed-rest-client, Redocly, AJV, express-rate-limit, Socket.IO, Vite/PostCSS, Metro, or image-size dependency changes." }, "overrideRegistry": [ diff --git a/governance/documentation-policy.json b/governance/documentation-policy.json index 5b3f711ae..05914ffd2 100644 --- a/governance/documentation-policy.json +++ b/governance/documentation-policy.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "owner": "ts-stack-maintainers", - "lastReviewed": "2026-07-30", + "lastReviewed": "2026-08-30", "programHistory": "https://github.com/bsv-blockchain/ts-stack/issues/324", "contributionAuthority": "CONTRIBUTING.md", "activeFollowUps": { diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index 7dbb9568c..c2d1003b8 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -317,6 +317,16 @@ "maximumNoCoverage": 0, "maximumInvalid": 0 }, + { + "id": "ecpm-permission", + "manifest": "packages/wallet/ecpm-permission-module/package.json", + "propertyTest": "packages/wallet/ecpm-permission-module/src/__tests__/EcpmPermissionModule.property.test.ts", + "risk": "critical", + "boundary": "Wallet-derived secret scalars applied to untrusted caller-supplied secp256k1 points", + "minimumScore": 85, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, { "id": "chirp-codec", "manifest": "packages/network/chirp/package.json", diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index aa4510b65..b90e0064b 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -498,6 +498,22 @@ export function buildMutationTargets(repositoryRoot) { esm: true }) }, + 'ecpm-permission': { + packageDirectory: 'packages/wallet/ecpm-permission-module', + manifest: 'packages/wallet/ecpm-permission-module/package.json', + propertyTest: + 'packages/wallet/ecpm-permission-module/src/__tests__/EcpmPermissionModule.property.test.ts', + // Keep the defensive infinity checks in production, but omit them from mutation: + // canonical compressed points and nonzero PrivateKey scalars cannot reach either branch. + mutate: [ + 'src/EcpmPermissionModule.ts:37-203', + 'src/EcpmPermissionModule.ts:207-289', + 'src/EcpmPermissionModule.ts:293-298' + ], + ...jestTarget('jest.config.cjs', ['/src/__tests__/EcpmPermissionModule*.test.ts'], { + esm: true + }) + }, 'chirp-codec': { packageDirectory: 'packages/network/chirp', manifest: 'packages/network/chirp/package.json', diff --git a/governance/npm-package-supply-chain.json b/governance/npm-package-supply-chain.json index a4f0114e8..e37fd33ce 100644 --- a/governance/npm-package-supply-chain.json +++ b/governance/npm-package-supply-chain.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "artifactSchemaVersion": 1, - "publicPackageCount": 32, + "publicPackageCount": 33, "releaseWorkflow": ".github/workflows/release.yaml", "releaseEnvironment": "npm-production", "buildRuntime": { diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 3aa969433..c458306af 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -66,6 +66,13 @@ "summary": "Adopts the governed strict TypeScript profile and repository-wide zero-warning lint and formatting contract. Standardizes first-party author metadata on the current BSV Association name.", "migration": "No consumer migration is required; permission-module APIs and token semantics are unchanged." }, + { + "name": "@bsv/ecpm-permission-module", + "publishedVersion": "0.0.0", + "releaseType": "minor", + "summary": "Introduces the BRC-98 p ecpm semantic module, applying or removing a wallet-derived scalar from validated secp256k1 points through the existing BRC-100 getPublicKey surface, including reason-scoped privileged authorization and privileged-key-provider hooks.", + "migration": "No existing consumer migration is required; this is the first release. Wallet hosts register the module under the ecpm scheme and supply their ordinary key deriver, authorization handler, and optional privileged key provider." + }, { "name": "@bsv/chirp", "publishedVersion": "0.0.0", @@ -201,24 +208,24 @@ }, { "name": "@bsv/wallet-toolbox", - "publishedVersion": "2.10.0", - "releaseType": "patch", - "summary": "Retains BRC-95 and BRC-100 compatibility and stable bounded pagination, removes an obsolete exported JSight application bundle that lacked its required third-party license companion, preserves the package's earlier Open BSV grant in the distribution notice archive, and standardizes first-party author metadata on the current BSV Association name.", - "migration": "No runtime consumer migration is required. Canonical AtomicBEEF, number-array behavior, and pagination contracts are unchanged; upgrade to @bsv/sdk 2.4.2 or later. Documentation users should use docs/storage.md instead of the removed JSight export. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/." + "publishedVersion": "2.10.4", + "releaseType": "minor", + "summary": "Adds the optional semantic handleRequest hook to BRC-98/99/111 permission modules while retaining the existing transformation hooks, BRC-95/BRC-100 compatibility, and stable bounded pagination. It also removes the obsolete JSight application bundle and preserves the package's earlier Open BSV grant in the distribution notice archive.", + "migration": "Existing permission modules require no changes because onRequest and onResponse remain supported. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. Upgrade to @bsv/sdk 2.4.2 or later, use docs/storage.md instead of the removed JSight export, and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing." }, { "name": "@bsv/wallet-toolbox-client", - "publishedVersion": "2.10.0", - "releaseType": "patch", - "summary": "Carries the browser Wallet Toolbox internalization and BRC-100 compatibility fixes, stable IndexedDB totals, preserves earlier Open BSV grants in the distribution notice archive, and standardizes first-party author metadata on the current BSV Association name.", - "migration": "No consumer migration is required. Browser exports, wire types, canonical AtomicBEEF behavior, and pagination contracts are unchanged; use @bsv/sdk 2.4.2 or later. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/." + "publishedVersion": "2.10.4", + "releaseType": "minor", + "summary": "Exports the optional semantic handleRequest permission-module hook for browser and ESM wallet hosts while retaining transformation modules, BRC-100 wire compatibility, stable IndexedDB totals, and the current browser Wallet Toolbox compatibility fixes. It preserves earlier Open BSV grants in the distribution notice archive.", + "migration": "Existing permission modules require no changes. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registration under the ecpm scheme. Use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing." }, { "name": "@bsv/wallet-toolbox-mobile", - "publishedVersion": "2.10.0", - "releaseType": "patch", - "summary": "Carries the mobile Wallet Toolbox internalization and BRC-100 compatibility fixes, preserves earlier Open BSV grants in the distribution notice archive, and standardizes first-party author metadata on the current BSV Association name.", - "migration": "No consumer migration is required. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.2 or later. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/." + "publishedVersion": "2.10.4", + "releaseType": "minor", + "summary": "Exports the optional semantic handleRequest permission-module hook for React Native wallet hosts while retaining transformation modules, BRC-100 wire compatibility, and the current mobile Wallet Toolbox compatibility fixes. It preserves earlier Open BSV grants in the distribution notice archive.", + "migration": "Existing permission modules require no changes. Semantic modules may add handleRequest; mobile hosts can register compatible semantic modules without changing the Wallet interface. Use @bsv/sdk 2.4.2 or later and retain THIRD_PARTY_NOTICES.md and LICENSES/ when redistributing." }, { "name": "create-bsv-app", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index f972de86c..c61d49f75 100644 --- a/governance/repository-health/baselines.json +++ b/governance/repository-health/baselines.json @@ -4,9 +4,9 @@ "sourceRevision": "f9137ff037c6d608019d04b4e2f984812b0385b7", "tracker": "https://github.com/bsv-blockchain/ts-stack/issues/324", "workspace": { - "projects": 39, - "packageAreaProjects": 35, - "publicPackages": 32, + "projects": 40, + "packageAreaProjects": 36, + "publicPackages": 33, "privatePackageAreaProjects": 3 }, "ci": { @@ -325,9 +325,10 @@ "@bsv/verifast": "0.3.5", "@bsv/btms": "1.2.2", "@bsv/btms-permission-module": "1.1.4", + "@bsv/ecpm-permission-module": "0.1.0", "@bsv/wallet-relay": "0.3.6", - "@bsv/wallet-toolbox-client": "2.10.4", - "@bsv/wallet-toolbox-mobile": "2.10.4", - "@bsv/wallet-toolbox": "2.10.4" + "@bsv/wallet-toolbox-client": "2.11.0", + "@bsv/wallet-toolbox-mobile": "2.11.0", + "@bsv/wallet-toolbox": "2.11.0" } } diff --git a/governance/repository-health/exceptions.json b/governance/repository-health/exceptions.json index a2b552e1e..128af7e88 100644 --- a/governance/repository-health/exceptions.json +++ b/governance/repository-health/exceptions.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "lastReviewed": "2026-08-24", + "lastReviewed": "2026-08-30", "exceptions": [ { "id": "scorecard-maintainer-merge-policy", @@ -49,7 +49,7 @@ ".github/dependabot.yml" ], "created": "2026-07-27", - "reviewBy": "2026-08-27", + "reviewBy": "2026-09-30", "removeWhen": "Remove after TypeScript exposes a stable native API, every compiler-API consumer supports it without an override, and the full build, typecheck, declaration, packed-consumer, Jest, conformance, browser/mobile, and infrastructure matrix passes without @typescript/typescript6." }, { @@ -105,7 +105,7 @@ "https://github.com/bsv-blockchain/ts-stack/issues/324" ], "created": "2026-07-29", - "reviewBy": "2026-08-28", + "reviewBy": "2026-09-30", "removeWhen": "Remove service by service when its complete supported Google and OpenTelemetry graph natively resolves gaxios 7.3.0 or newer and frozen audit, build, test, and Linux image checks remain green without the substitution." }, { @@ -124,7 +124,7 @@ "https://github.com/bsv-blockchain/ts-stack/issues/324" ], "created": "2026-07-29", - "reviewBy": "2026-08-28", + "reviewBy": "2026-09-30", "removeWhen": "Remove from each remaining service when its supported cloud client closure natively resolves uuid 11.1.1 or newer and frozen audit, build, test, and Linux image checks remain green without the substitution." }, { @@ -132,7 +132,7 @@ "category": "override", "target": "pnpm-workspace.yaml override typed-rest-client@2.3.1>qs", "owner": "ts-stack-maintainers", - "reason": "Stryker 9.6.1 is current but its current typed-rest-client 2.3.1 dependency pins vulnerable qs 6.15.1 exactly. GHSA-q8mj-m7cp-5q26 is fixed in qs 6.15.2 and later, so a parent-scoped substitution to 6.15.3 is the narrowest durable remediation. The previous lock-only selection was lost when an unrelated dependency change regenerated the graph.", + "reason": "The supported Stryker 9.6.1 graph and current Stryker 10.0.0 both retain the typed-rest-client 2.3.x line, whose current 2.3.1 release pins vulnerable qs 6.15.1 exactly. GHSA-q8mj-m7cp-5q26 is fixed in qs 6.15.2 and later, so a parent-scoped substitution to 6.15.3 remains the narrowest compatible remediation.", "evidence": [ "pnpm-workspace.yaml#overrides", "https://github.com/advisories/GHSA-q8mj-m7cp-5q26", @@ -140,7 +140,7 @@ "https://github.com/bsv-blockchain/ts-stack/issues/324" ], "created": "2026-07-27", - "reviewBy": "2026-08-27", + "reviewBy": "2026-09-30", "removeWhen": "Remove when Stryker no longer depends on typed-rest-client 2.3.1 or a supported typed-rest-client release natively depends on qs 6.15.2 or newer, then regenerate the lock and rerun the complete mutation campaign." }, { diff --git a/governance/repository-health/projects.json b/governance/repository-health/projects.json index 667150c45..0656f8772 100644 --- a/governance/repository-health/projects.json +++ b/governance/repository-health/projects.json @@ -717,6 +717,17 @@ "runtimeTargets": ["node"], "release": "npm-oidc" }, + { + "path": "packages/wallet/ecpm-permission-module", + "name": "@bsv/ecpm-permission-module", + "owner": "ts-stack-maintainers", + "area": "wallet", + "profile": "browser-library", + "consumerProfiles": ["browser-bundler", "browser-esm", "node-esm"], + "criticality": "tier-1", + "runtimeTargets": ["browser", "node"], + "release": "npm-oidc" + }, { "path": "packages/wallet/ts-wallet-relay", "name": "@bsv/wallet-relay", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 7d1d37e8b..1cb775213 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -41,6 +41,7 @@ "packages/overlays/gasp-core/package.json", "packages/overlays/btms-backend/package.json", "packages/wallet/btms-permission-module/package.json", + "packages/wallet/ecpm-permission-module/package.json", "packages/network/chirp/package.json" ], "suites": [ @@ -425,6 +426,17 @@ "Array-shaped request arguments are rejected at the authorization boundary." ] }, + { + "path": "packages/wallet/ecpm-permission-module/src/__tests__/EcpmPermissionModule.property.test.ts", + "manifest": "packages/wallet/ecpm-permission-module/package.json", + "risk": "critical", + "boundary": "Wallet-derived secret scalars applied to untrusted caller-supplied secp256k1 points", + "target": "BRC-98 ECPM apply/remove inversion across independently generated wallet roots and finite curve points", + "invariants": [ + "Applying and then removing one module-derived scalar returns every generated finite input point exactly.", + "Neither randomized root material nor the corresponding derived scalar is exposed in the wallet result." + ] + }, { "path": "packages/network/chirp/test/codec.property.test.ts", "manifest": "packages/network/chirp/package.json", diff --git a/governance/test-quality/wallet-toolbox-manual-suites.json b/governance/test-quality/wallet-toolbox-manual-suites.json index 37f57bcf3..529e85867 100644 --- a/governance/test-quality/wallet-toolbox-manual-suites.json +++ b/governance/test-quality/wallet-toolbox-manual-suites.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "lastReviewed": "2026-07-29", + "lastReviewed": "2026-08-30", "owner": "ts-stack-maintainers", - "reviewBy": "2026-08-29", + "reviewBy": "2026-09-30", "suites": [ { "path": "packages/wallet/wallet-toolbox/src/services/__tests/ARC.man.test.ts", diff --git a/packages/wallet/ecpm-permission-module/AGENTS.md b/packages/wallet/ecpm-permission-module/AGENTS.md new file mode 100644 index 000000000..dcea67c80 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/AGENTS.md @@ -0,0 +1,10 @@ +# ts-stack agent instructions + +This project follows the repository-wide [agent instructions](../../../AGENTS.md) +and [contribution policy](../../../CONTRIBUTING.md). Read and follow both files +before changing anything in this directory. + +Do not add package-local agent or contribution conventions. Put +package-specific technical information in the package README, `docs/`, +`specs/`, or the applicable operator guide, and propose shared policy at the +repository root. diff --git a/packages/wallet/ecpm-permission-module/LICENSE.txt b/packages/wallet/ecpm-permission-module/LICENSE.txt new file mode 100644 index 000000000..15e819500 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/LICENSE.txt @@ -0,0 +1,58 @@ +Open BSV License Version 6 – granted by BSV Association, Alpenstrasse 15, 6300 +Zug, Switzerland (CHE-427.008.338) ("Licensor"), to you as a user (henceforth +"You", "User" or "Licensee"). + +For the purposes of this license, the definitions below have the following +meanings: + +"Bitcoin Protocol" means the protocol implementation, cryptographic rules, +network protocols, and consensus mechanisms in the Bitcoin White Paper as +described here https://protocol.bsvblockchain.org. + +"Bitcoin White Paper" means the paper entitled 'Bitcoin: A Peer-to-Peer +Electronic Cash System' published by 'Satoshi Nakamoto' in October 2008. + +"BSV Blockchain" means: + + (a) the Bitcoin blockchain containing block height #556767 with the hash + "000000000000000001d956714215d96ffc00e0afda4cd0a96c96f8d802b1662b" and + that contains the longest honest persistent chain of blocks which has been + produced in a manner which is consistent with the rules set forth in the + Network Access Rules; and + (b) the test blockchains that contain the longest honest persistent chains of + blocks which has been produced in a manner which is consistent with the + rules set forth in the Network Access Rules. + +"Network Access Rules" or "Rules" means the set of rules regulating the +relationship between BSV Association and the nodes on BSV based on the Bitcoin +Protocol rules and those set out in the Bitcoin White Paper, and available here +https://bsvblockchain.org/network-access-rules. + +"Software" means the software the subject of this license, including any/all +intellectual property rights therein and associated documentation files. + +BSV Association grants permission, free of charge and on a non-exclusive basis +to any person obtaining a copy of the Software to deal in the Software, including +without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to and conditioned upon the following +conditions: + +1 - The text "© BSV Association", and this license shall be included in all +copies or substantial portions of the Software. + +2 - The Software, and any software that is derived from the Software or parts +thereof, may only be used exclusively on the BSV Blockchain. + +For the avoidance of doubt, this license is granted subject to and conditioned +upon your compliance with these terms only and is limited to uses on the BSV +Blockchain. Any exercise of rights not compliant with these terms including +use not for the BSV Blockchain is deemed outside the scope of the license. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES REGARDING ENTITLEMENT, +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS THEREOF BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/packages/wallet/ecpm-permission-module/README.md b/packages/wallet/ecpm-permission-module/README.md new file mode 100644 index 000000000..331c5dedd --- /dev/null +++ b/packages/wallet/ecpm-permission-module/README.md @@ -0,0 +1,118 @@ +# ECPM Permission Module + +`@bsv/ecpm-permission-module` implements the BRC-229 `p ecpm` semantic module +for BRC-100 wallets. It applies or removes a BRC-42/43-derived scalar to an +arbitrary validated secp256k1 point without adding a method or wire call to the +fixed BRC-100 interface. + +## Protocol + +Applications call the existing `getPublicKey` method with: + +```text +p ecpm +``` + +The logical protocol ID is 5–273 ASCII bytes. The complete dispatch envelope +may be 353 bytes for `apply` or 354 bytes for `remove`; this preserves the full +BRC-43 logical protocol namespace while remaining inside BRC-100's 400-byte +protocol-string limit. + +The security level remains in the normal BRC-43 tuple. The key ID, +counterparty, privileged selection, privileged reason, and permission behavior +remain in their existing `getPublicKey` fields. + +```ts +const applied = await wallet.getPublicKey({ + protocolID: [2, `p ecpm apply ${pointHex} mental poker deal`], + keyID: 'deck mask', + counterparty: 'self' +}) + +const removed = await wallet.getPublicKey({ + protocolID: [2, `p ecpm remove ${applied.publicKey} mental poker deal`], + keyID: 'deck mask', + counterparty: 'self' +}) +``` + +For both calls, the module derives the scalar under the canonical namespace +`p ecpm mental poker deal`. The operation and point are deliberately excluded +from the BRC-42 invoice so every point uses the same scalar and `remove` +selects the inverse of the scalar used by `apply`. + +## Installation + +Create the module with the wallet's ordinary `KeyDeriverApi` and an +authorization callback, then register it under the `ecpm` scheme: + +```ts +import { createEcpmModule } from '@bsv/ecpm-permission-module' +import { WalletPermissionsManager } from '@bsv/wallet-toolbox-client' + +const ecpm = createEcpmModule({ + keyDeriver: setup.keyDeriver, + authorize: async request => { + return await showTrustedWalletPrompt({ + originator: request.originator, + protocol: request.logicalProtocolID, + counterparty: request.counterparty, + privileged: request.privileged + }) + }, + privilegedKeyDeriver: async reason => { + return await acquirePrivilegedKeyDeriver(reason) + } +}) + +const wallet = new WalletPermissionsManager(setup.wallet, adminOriginator, { + permissionModules: { ecpm } +}) +``` + +Call `ecpm.dispose()` when the host tears down the wallet. The method clears +cached and pending authorization state. + +Security level 0 primary-key requests do not prompt. Levels 1 and 2 require +the authorization callback, with level 2 grants scoped to the counterparty. +Every privileged request requires authorization regardless of security level, +and cached or in-flight privileged grants are scoped to the exact approved +`privilegedReason`. Changing the reason requires a separate authorization. +`seekPermission: false` fails unless an applicable grant is already cached. + +## Security model + +The module: + +- accepts only `getPublicKey` in the `p ecpm` namespace, preventing the same + derived key from being reused for signatures, HMACs, or BRC-2 encryption; +- rejects identity-key and `forSelf` requests; +- keeps the point and operation outside the derived-key identity; +- isolates ordinary and privileged derivation providers; +- checks the encoded x coordinate before parsing so a reducing parser cannot + accept a non-canonical point; +- accepts only finite, on-curve, lowercase compressed secp256k1 points; and +- returns the existing `{ publicKey }` result shape, so no BRC-100 wire change + is required. + +The module is trusted wallet code. Applications never receive a key deriver or +private scalar. A privileged provider should acquire protected key material +only after its reason has been displayed and authorized, and should retain it +for no longer than the host wallet's existing privileged-key policy permits. + +## Verification + +```bash +pnpm --filter @bsv/sdk build +pnpm --filter @bsv/wallet-toolbox-client build +pnpm --filter @bsv/ecpm-permission-module typecheck +pnpm --filter @bsv/ecpm-permission-module lint +pnpm --filter @bsv/ecpm-permission-module test:coverage +pnpm --filter @bsv/ecpm-permission-module test:property +pnpm --filter @bsv/ecpm-permission-module build +pnpm --filter @bsv/ecpm-permission-module pack:check +``` + +## License + +Open BSV License version 6. See `LICENSE.txt`. diff --git a/packages/wallet/ecpm-permission-module/browser-budget.json b/packages/wallet/ecpm-permission-module/browser-budget.json new file mode 100644 index 000000000..ac6f0bdee --- /dev/null +++ b/packages/wallet/ecpm-permission-module/browser-budget.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "profile": "browser", + "package": "@bsv/ecpm-permission-module", + "entry": ".", + "requiredExports": ["EcpmPermissionModule", "createEcpmModule"], + "prohibitedExports": [], + "maximumBytes": { + "vite": { "raw": 110000, "gzip": 38000, "brotli": 32000 }, + "esbuild": { "raw": 114000, "gzip": 43000, "brotli": 37000 } + } +} diff --git a/packages/wallet/ecpm-permission-module/jest.config.cjs b/packages/wallet/ecpm-permission-module/jest.config.cjs new file mode 100644 index 000000000..e816f8349 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/jest.config.cjs @@ -0,0 +1,38 @@ +/** @type {import('jest').Config} */ +module.exports = { + bail: 1, + collectCoverageFrom: ['src/**/*.ts', '!src/**/__tests__/**'], + coverageDirectory: 'coverage', + coverageThreshold: { + global: { + branches: 85, + functions: 90, + lines: 90, + statements: 90 + } + }, + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1' + }, + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + testMatch: ['**/__tests__/**/*.test.ts'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + allowSyntheticDefaultImports: true, + esModuleInterop: true, + module: 'ESNext', + moduleResolution: 'bundler', + strict: true, + target: 'ES2022', + types: ['jest', 'node'] + } + } + ] + }, + verbose: true +} diff --git a/packages/wallet/ecpm-permission-module/package.json b/packages/wallet/ecpm-permission-module/package.json new file mode 100644 index 000000000..d1108badd --- /dev/null +++ b/packages/wallet/ecpm-permission-module/package.json @@ -0,0 +1,78 @@ +{ + "name": "@bsv/ecpm-permission-module", + "version": "0.1.0", + "sideEffects": false, + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public" + }, + "description": "BRC-98 ECPM semantic module for elliptic-curve point multiplication in BRC-100 wallets", + "type": "module", + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + } + } + }, + "files": [ + "dist", + "README.md", + "LICENSE.txt" + ], + "scripts": { + "build": "tsdown src/index.ts --format esm --dts --sourcemap --clean --out-dir dist --tsconfig tsconfig.build.json", + "clean": "rm -rf dist", + "format:check": "pnpm --workspace-root exec prettier --check \"packages/wallet/ecpm-permission-module/src/**/*.ts\" \"packages/wallet/ecpm-permission-module/README.md\" \"packages/wallet/ecpm-permission-module/*.{cjs,json,ts}\"", + "lint": "oxlint src --deny-warnings", + "pack:check": "node ../../../scripts/check-package-artifact.mjs . --modes esm --exports EcpmPermissionModule,createEcpmModule", + "test": "jest", + "test:browser": "pnpm build && node ../../../scripts/check-browser-package.mjs .", + "test:coverage": "jest --coverage", + "test:property": "jest --runInBand --runTestsByPath src/__tests__/EcpmPermissionModule.property.test.ts", + "typecheck": "tsc --project tsconfig.typecheck.json" + }, + "keywords": [ + "brc-98", + "brc-229", + "ecpm", + "permissions", + "wallet", + "BSV" + ], + "author": "BSV Association", + "license": "SEE LICENSE IN LICENSE.txt", + "peerDependencies": { + "@bsv/sdk": "^2.4.1", + "@bsv/wallet-toolbox-client": "^2.11.0" + }, + "devDependencies": { + "@bsv/sdk": "workspace:^", + "@bsv/wallet-toolbox-client": "workspace:^", + "@jest/globals": "^30.4.1", + "@types/jest": "^30.0.0", + "@types/node": "^26.1.2", + "@typescript/native": "npm:typescript@7.0.2", + "fast-check": "^4.9.0", + "jest": "^30.4.2", + "oxlint": "^1.76.0", + "ts-jest": "^29.4.12", + "tsdown": "0.22.14", + "typescript": "npm:@typescript/typescript6@6.0.2" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/bsv-blockchain/ts-stack.git", + "directory": "packages/wallet/ecpm-permission-module" + }, + "bugs": { + "url": "https://github.com/bsv-blockchain/ts-stack/issues" + }, + "homepage": "https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ecpm-permission-module#readme" +} diff --git a/packages/wallet/ecpm-permission-module/src/EcpmPermissionModule.ts b/packages/wallet/ecpm-permission-module/src/EcpmPermissionModule.ts new file mode 100644 index 000000000..e28e540dd --- /dev/null +++ b/packages/wallet/ecpm-permission-module/src/EcpmPermissionModule.ts @@ -0,0 +1,300 @@ +import { + BigNumber, + Curve, + Point, + Utils, + type GetPublicKeyArgs, + type PubKeyHex, + type SecurityLevel +} from '@bsv/sdk' +import type { + PermissionsModule, + PermissionsModuleNext, + PermissionsModuleRequest +} from '@bsv/wallet-toolbox-client' +import type { + EcpmAuthorizationRequest, + EcpmKeyDeriver, + EcpmMultiplyInput, + EcpmPermissionModuleOptions, + ParsedEcpmRequest +} from './types.js' + +const ECPM_PATTERN = /^p ecpm (apply|remove) (0[23][0-9a-f]{64}) ([a-z0-9]+(?: [a-z0-9]+)*)$/ +const MAX_ECPM_PROTOCOL_BYTES = 354 +const MAX_LOGICAL_PROTOCOL_BYTES = 273 +const DEFAULT_AUTHORIZATION_TTL = 5 * 60 * 1000 +const MAX_AUTHORIZATION_TTL = 24 * 60 * 60 * 1000 + +/** Implements the BRC-229 `p ecpm` semantic permission module. */ +export class EcpmPermissionModule implements PermissionsModule { + private readonly keyDeriver: EcpmKeyDeriver + private readonly privilegedKeyDeriver: EcpmPermissionModuleOptions['privilegedKeyDeriver'] + private readonly authorize: EcpmPermissionModuleOptions['authorize'] + private readonly authorizationTTL: number + private readonly grants = new Map() + private readonly pendingGrants = new Map>() + + constructor(options: EcpmPermissionModuleOptions) { + if (options?.keyDeriver == null || typeof options.keyDeriver.derivePrivateKey !== 'function') { + throw new TypeError('ECPM: keyDeriver with derivePrivateKey is required') + } + const authorizationTTL = options.authorizationTTL ?? DEFAULT_AUTHORIZATION_TTL + if ( + !Number.isSafeInteger(authorizationTTL) || + authorizationTTL <= 0 || + authorizationTTL > MAX_AUTHORIZATION_TTL + ) { + throw new RangeError('ECPM: authorizationTTL must be between 1 ms and 24 hours') + } + this.keyDeriver = options.keyDeriver + this.privilegedKeyDeriver = options.privilegedKeyDeriver + this.authorize = options.authorize + this.authorizationTTL = authorizationTTL + } + + /** Clears cached and pending authorization state. */ + dispose(): void { + this.grants.clear() + this.pendingGrants.clear() + } + + /** Semantic P-module entry point; ECPM never forwards to ordinary `getPublicKey`. */ + async handleRequest( + request: PermissionsModuleRequest, + _next: PermissionsModuleNext + ): Promise<{ publicKey: PubKeyHex }> { + if (request.method !== 'getPublicKey') { + throw new Error(`ECPM: ${request.method} is not permitted in the p ecpm namespace`) + } + if (typeof request.originator !== 'string' || request.originator.length === 0) { + throw new Error('ECPM: originator is required') + } + + const parsed = this.parseRequest(request.args) + await this.ensureAuthorized(parsed, request.originator) + const keyDeriver = await this.selectKeyDeriver(parsed) + const derivedKey = keyDeriver.derivePrivateKey( + parsed.derivationProtocolID, + parsed.keyID, + parsed.counterparty + ) + return { + publicKey: this.multiply({ + point: parsed.point, + derivedKey, + operation: parsed.operation + }) + } + } + + async onRequest(request: PermissionsModuleRequest): Promise<{ args: object }> { + return { args: request.args } + } + + async onResponse(result: unknown): Promise { + return result + } + + private parseRequest(rawArgs: object): ParsedEcpmRequest { + if (rawArgs == null || typeof rawArgs !== 'object' || Array.isArray(rawArgs)) { + throw new TypeError('ECPM: getPublicKey arguments must be an object') + } + const args = rawArgs as GetPublicKeyArgs + if (args.identityKey === true) { + throw new Error('ECPM: identityKey is prohibited') + } + if (args.forSelf === true) { + throw new Error('ECPM: forSelf is not defined for this module') + } + if (!Array.isArray(args.protocolID) || args.protocolID.length !== 2) { + throw new Error('ECPM: protocolID is required') + } + const [securityLevel, protocolName] = args.protocolID + if (!this.isSecurityLevel(securityLevel) || typeof protocolName !== 'string') { + throw new Error('ECPM: invalid protocolID') + } + if (Utils.toArray(protocolName, 'utf8').length > MAX_ECPM_PROTOCOL_BYTES) { + throw new Error('ECPM: outer protocol ID exceeds 354 bytes') + } + const match = ECPM_PATTERN.exec(protocolName) + if (match == null) { + throw new Error('ECPM: protocol must be p ecpm ') + } + + const operation = match[1] as 'apply' | 'remove' + const point = match[2] as PubKeyHex + const logicalProtocolID = match[3] + this.validateLogicalProtocol(logicalProtocolID) + this.parseValidPoint(point) + + const keyID = args.keyID + if (typeof keyID !== 'string' || Utils.toArray(keyID, 'utf8').length < 1) { + throw new Error('ECPM: keyID is required') + } + if (Utils.toArray(keyID, 'utf8').length > 800) { + throw new Error('ECPM: keyID exceeds 800 bytes') + } + + const counterparty = (args.counterparty ?? 'self') as PubKeyHex | 'self' | 'anyone' + this.validateCounterparty(counterparty) + const privileged = args.privileged === true + const privilegedReason = args.privilegedReason + if (privileged) this.validatePrivilegedReason(privilegedReason) + + return { + args, + operation, + point, + logicalProtocolID, + derivationProtocolID: [securityLevel, `p ecpm ${logicalProtocolID}`], + keyID, + counterparty, + privileged, + privilegedReason + } + } + + private validateLogicalProtocol(logicalProtocolID: string): void { + const bytes = Utils.toArray(logicalProtocolID, 'utf8').length + if (bytes < 5 || bytes > MAX_LOGICAL_PROTOCOL_BYTES) { + throw new Error('ECPM: logical protocol ID must be between 5 and 273 bytes') + } + if ( + logicalProtocolID.includes(' ') || + logicalProtocolID.endsWith(' protocol') || + !/^[a-z0-9 ]+$/.test(logicalProtocolID) + ) { + throw new Error('ECPM: invalid logical protocol ID') + } + } + + private validateCounterparty(counterparty: PubKeyHex | 'self' | 'anyone'): void { + if (counterparty === 'self' || counterparty === 'anyone') return + if (typeof counterparty !== 'string') { + throw new TypeError('ECPM: counterparty must be self, anyone, or a compressed public key') + } + this.parseValidPoint(counterparty) + } + + private validatePrivilegedReason(reason: string | undefined): void { + if (typeof reason !== 'string') { + throw new TypeError('ECPM: privilegedReason is required for privileged operations') + } + const bytes = Utils.toArray(reason, 'utf8').length + if (bytes < 5 || bytes > 50) { + throw new Error('ECPM: privilegedReason must be between 5 and 50 bytes') + } + } + + private parseValidPoint(pointHex: PubKeyHex): Point { + if (!/^0[23][0-9a-f]{64}$/.test(pointHex)) { + throw new Error('ECPM: expected a lowercase 33-byte compressed secp256k1 point') + } + const curve = new Curve() + if (new BigNumber(pointHex.slice(2), 16).cmp(curve.p) >= 0) { + throw new Error('ECPM: x is not a canonical field element') + } + let point: Point + try { + point = Point.fromString(pointHex) + } catch { + throw new Error('ECPM: point could not be decoded') + } + if (point.isInfinity() || !point.validate()) { + throw new Error('ECPM: point is not a finite secp256k1 point') + } + return point + } + + private async ensureAuthorized(parsed: ParsedEcpmRequest, originator: string): Promise { + const authorization = this.authorizationRequest(parsed, originator) + if (authorization.securityLevel === 0 && !authorization.privileged) return + + const scope = this.authorizationScope(authorization) + const now = Date.now() + const expiry = this.grants.get(scope) + if (expiry != null && expiry > now) return + this.grants.delete(scope) + + if (parsed.args.seekPermission === false) { + throw new Error('ECPM: permission is required and seekPermission is false') + } + if (this.authorize == null) { + throw new Error('ECPM: no authorization handler is configured') + } + + let pending = this.pendingGrants.get(scope) + if (pending == null) { + pending = Promise.resolve(this.authorize(authorization)) + this.pendingGrants.set(scope, pending) + } + let approved: boolean + try { + approved = await pending + } finally { + if (this.pendingGrants.get(scope) === pending) this.pendingGrants.delete(scope) + } + if (approved !== true) throw new Error('ECPM: user denied permission') + this.grants.set(scope, Date.now() + this.authorizationTTL) + } + + private authorizationRequest( + parsed: ParsedEcpmRequest, + originator: string + ): EcpmAuthorizationRequest { + return { + originator, + securityLevel: parsed.derivationProtocolID[0], + logicalProtocolID: parsed.logicalProtocolID, + keyID: parsed.keyID, + counterparty: parsed.counterparty, + privileged: parsed.privileged, + privilegedReason: parsed.privilegedReason, + operation: parsed.operation, + point: parsed.point + } + } + + private authorizationScope(request: EcpmAuthorizationRequest): string { + const counterparty = request.securityLevel === 2 ? request.counterparty : '*' + return JSON.stringify([ + request.originator, + request.securityLevel, + request.logicalProtocolID, + counterparty, + request.privileged ? 'privileged' : 'primary', + request.privileged ? request.privilegedReason : null + ]) + } + + private async selectKeyDeriver(parsed: ParsedEcpmRequest): Promise { + if (!parsed.privileged) return this.keyDeriver + if (this.privilegedKeyDeriver == null) { + throw new Error('ECPM: privileged key derivation is unavailable') + } + const deriver = await this.privilegedKeyDeriver(parsed.privilegedReason!) + if (deriver == null || typeof deriver.derivePrivateKey !== 'function') { + throw new Error('ECPM: privileged key provider returned an invalid deriver') + } + return deriver + } + + private multiply(input: EcpmMultiplyInput): PubKeyHex { + const point = this.parseValidPoint(input.point) + const curve = new Curve() + const scalar = + input.operation === 'remove' + ? input.derivedKey.invm(curve.n) + : new BigNumber(input.derivedKey.toHex(), 16) + const result = point.mul(scalar) + if (result.isInfinity()) { + throw new Error('ECPM: result is the point at infinity') + } + return result.encode(true, 'hex') as PubKeyHex + } + + private isSecurityLevel(value: unknown): value is SecurityLevel { + return value === 0 || value === 1 || value === 2 + } +} diff --git a/packages/wallet/ecpm-permission-module/src/__tests__/EcpmPermissionModule.property.test.ts b/packages/wallet/ecpm-permission-module/src/__tests__/EcpmPermissionModule.property.test.ts new file mode 100644 index 000000000..a5b3a31a6 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/src/__tests__/EcpmPermissionModule.property.test.ts @@ -0,0 +1,53 @@ +import fc from 'fast-check' +import { BigNumber, CachedKeyDeriver, Curve, PrivateKey, type PubKeyHex } from '@bsv/sdk' +import { EcpmPermissionModule } from '../EcpmPermissionModule.js' + +const curve = new Curve() +const MIN_PROPERTY_RUNS = 300 +const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) +const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) +const replayPath = process.env.FAST_CHECK_PATH + +fc.configureGlobal({ + numRuns: Number.isSafeInteger(requestedRuns) + ? Math.max(MIN_PROPERTY_RUNS, requestedRuns) + : MIN_PROPERTY_RUNS, + ...(Number.isSafeInteger(requestedSeed) ? { seed: requestedSeed } : {}), + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) +}) + +describe('EcpmPermissionModule properties', () => { + it('round-trips every generated non-zero root and point scalar', async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 1, max: 1_000_000 }), + fc.integer({ min: 1, max: 1_000_000 }), + async (rootScalar, pointScalar) => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(rootScalar)) + }) + const original = curve.g.mul(new BigNumber(pointScalar)).encode(true, 'hex') as PubKeyHex + const call = async (operation: 'apply' | 'remove', point: PubKeyHex) => + await module.handleRequest!( + { + method: 'getPublicKey', + originator: 'property.example', + args: { + protocolID: [0, `p ecpm ${operation} ${point} property poker game`], + keyID: 'property key', + counterparty: 'self' + } + }, + async () => { + throw new Error('unexpected underlying call') + } + ) + + const applied = await call('apply', original) + const removed = await call('remove', applied.publicKey) + expect(removed.publicKey).toBe(original) + } + ) + ) + }) +}) diff --git a/packages/wallet/ecpm-permission-module/src/__tests__/EcpmPermissionModule.test.ts b/packages/wallet/ecpm-permission-module/src/__tests__/EcpmPermissionModule.test.ts new file mode 100644 index 000000000..ce37bd636 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/src/__tests__/EcpmPermissionModule.test.ts @@ -0,0 +1,578 @@ +import { + BigNumber, + CachedKeyDeriver, + Curve, + PrivateKey, + type GetPublicKeyArgs, + type PubKeyHex +} from '@bsv/sdk' +import { jest } from '@jest/globals' +import { EcpmPermissionModule } from '../EcpmPermissionModule.js' +import { createEcpmModule } from '../index.js' + +const curve = new Curve() +const point = (scalar: number): PubKeyHex => + curve.g.mul(new BigNumber(scalar)).encode(true, 'hex') as PubKeyHex + +const requestArgs = ( + input: PubKeyHex, + operation: 'apply' | 'remove' = 'apply', + overrides: Partial = {} +): GetPublicKeyArgs => ({ + protocolID: [0, `p ecpm ${operation} ${input} mental poker deal`], + keyID: 'deck mask', + counterparty: 'self', + ...overrides +}) + +const execute = async ( + module: EcpmPermissionModule, + args: GetPublicKeyArgs, + method = 'getPublicKey', + originator = 'poker.example' +): Promise<{ publicKey: PubKeyHex }> => + await module.handleRequest!({ method, args, originator }, async () => { + throw new Error('underlying wallet must not be called') + }) + +const observe = ( + promise: Promise +): Promise<{ ok: true; value: T } | { ok: false; error: unknown }> => + promise.then( + value => ({ ok: true, value }), + error => ({ ok: false, error }) + ) + +describe('EcpmPermissionModule', () => { + it('applies and removes the same derived scalar without exposing it', async () => { + const keyDeriver = new CachedKeyDeriver(new PrivateKey(11)) + const module = new EcpmPermissionModule({ keyDeriver }) + const original = point(7) + + const applied = await execute(module, requestArgs(original)) + const removed = await execute(module, requestArgs(applied.publicKey, 'remove')) + + expect(applied.publicKey).not.toBe(original) + expect(removed.publicKey).toBe(original) + }) + + it('exports a factory and harmless legacy transformation hooks', async () => { + const module = createEcpmModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(12)) + }) + const request = { + method: 'getPublicKey', + args: requestArgs(point(3)), + originator: 'poker.example' + } + + await expect(module.onRequest(request)).resolves.toEqual({ args: request.args }) + await expect(module.onResponse({ publicKey: point(4) })).resolves.toEqual({ + publicKey: point(4) + }) + }) + + it('agrees with direct multiplication by the canonical module-derived key', async () => { + const keyDeriver = new CachedKeyDeriver(new PrivateKey(19)) + const module = new EcpmPermissionModule({ keyDeriver }) + const original = point(13) + const derived = keyDeriver.derivePrivateKey( + [0, 'p ecpm mental poker deal'], + 'deck mask', + 'self' + ) + const expected = curve.g + .mul(new BigNumber(13)) + .mul(new BigNumber(derived.toHex(), 16)) + .encode(true, 'hex') + + await expect(execute(module, requestArgs(original))).resolves.toEqual({ + publicKey: expected + }) + }) + + it('accepts the inclusive protocol, key, reason, TTL, and counterparty boundaries', async () => { + const privileged = new CachedKeyDeriver(new PrivateKey(21)) + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(20)), + authorizationTTL: 24 * 60 * 60 * 1000, + authorize: async () => true, + privilegedKeyDeriver: async () => privileged + }) + const original = point(14) + + await expect( + execute( + module, + requestArgs(original, 'apply', { + protocolID: [0, `p ecpm apply ${original} abcde`], + keyID: 'x', + counterparty: 'anyone' + }) + ) + ).resolves.toHaveProperty('publicKey') + for (const [operation, outerLength] of [ + ['apply', 353], + ['remove', 354] + ] as const) { + const protocolName = `p ecpm ${operation} ${original} ${'a'.repeat(273)}` + expect(protocolName).toHaveLength(outerLength) + await expect( + execute( + module, + requestArgs(original, operation, { + protocolID: [0, protocolName], + keyID: 'x'.repeat(800) + }) + ) + ).resolves.toHaveProperty('publicKey') + } + for (const privilegedReason of ['abcde', 'x'.repeat(50)]) { + await expect( + execute( + module, + requestArgs(original, 'apply', { + privileged: true, + privilegedReason + }) + ) + ).resolves.toHaveProperty('publicKey') + } + }) + + it('commutes across independent wallet modules', async () => { + const alice = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(23)) + }) + const bob = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(29)) + }) + const original = point(5) + + const ab = await execute( + bob, + requestArgs((await execute(alice, requestArgs(original))).publicKey) + ) + const ba = await execute( + alice, + requestArgs((await execute(bob, requestArgs(original))).publicKey) + ) + + expect(ab).toEqual(ba) + }) + + it('excludes the operation and input point from the derivation identity', async () => { + const keyDeriver = new CachedKeyDeriver(new PrivateKey(31)) + const derivePrivateKey = jest.spyOn(keyDeriver, 'derivePrivateKey') + const module = new EcpmPermissionModule({ keyDeriver }) + + const first = await execute(module, requestArgs(point(2))) + await execute(module, requestArgs(first.publicKey, 'remove')) + + expect(derivePrivateKey).toHaveBeenNthCalledWith( + 1, + [0, 'p ecpm mental poker deal'], + 'deck mask', + 'self' + ) + expect(derivePrivateKey).toHaveBeenNthCalledWith( + 2, + [0, 'p ecpm mental poker deal'], + 'deck mask', + 'self' + ) + }) + + it('separates scalars by logical protocol, key ID, and counterparty', async () => { + const keyDeriver = new CachedKeyDeriver(new PrivateKey(37)) + const module = new EcpmPermissionModule({ keyDeriver }) + const original = point(17) + const counterparty = new PrivateKey(41).toPublicKey().toString() + + const results = await Promise.all([ + execute(module, requestArgs(original)), + execute( + module, + requestArgs(original, 'apply', { + protocolID: [0, `p ecpm apply ${original} another poker game`] + }) + ), + execute(module, requestArgs(original, 'apply', { keyID: 'another key' })), + execute(module, requestArgs(original, 'apply', { counterparty })) + ]) + + expect(new Set(results.map(result => result.publicKey)).size).toBe(4) + }) + + it('authorizes level 1 once per application and protocol, including concurrent calls', async () => { + let resolveAuthorization!: (approved: boolean) => void + const authorization = new Promise(resolve => { + resolveAuthorization = resolve + }) + const authorize = jest.fn(async () => await authorization) + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(43)), + authorize + }) + const args = requestArgs(point(3), 'apply', { + protocolID: [1, `p ecpm apply ${point(3)} mental poker deal`] + }) + + const first = observe(execute(module, args)) + const second = observe(execute(module, args)) + resolveAuthorization(true) + const outcomes = await Promise.all([first, second]) + expect(outcomes.every(outcome => outcome.ok)).toBe(true) + await execute( + module, + requestArgs(point(4), 'apply', { + protocolID: [1, `p ecpm apply ${point(4)} mental poker deal`], + keyID: 'another key' + }) + ) + + expect(authorize).toHaveBeenCalledTimes(1) + }) + + it('scopes level 2 grants by counterparty and clears them on dispose', async () => { + const authorize = jest.fn(async () => true) + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(47)), + authorize + }) + const firstCounterparty = new PrivateKey(53).toPublicKey().toString() + const secondCounterparty = new PrivateKey(59).toPublicKey().toString() + const levelTwo = (counterparty: string): GetPublicKeyArgs => + requestArgs(point(6), 'apply', { + protocolID: [2, `p ecpm apply ${point(6)} mental poker deal`], + counterparty + }) + + await execute(module, levelTwo(firstCounterparty)) + await execute(module, levelTwo(firstCounterparty)) + await execute(module, levelTwo(secondCounterparty)) + module.dispose() + await execute(module, levelTwo(firstCounterparty)) + + expect(authorize).toHaveBeenCalledTimes(3) + }) + + it('honors seekPermission false and denial without calling the key deriver', async () => { + const keyDeriver = new CachedKeyDeriver(new PrivateKey(61)) + const derivePrivateKey = jest.spyOn(keyDeriver, 'derivePrivateKey') + const authorize = jest.fn(async () => false) + const module = new EcpmPermissionModule({ keyDeriver, authorize }) + const levelOne = requestArgs(point(8), 'apply', { + protocolID: [1, `p ecpm apply ${point(8)} mental poker deal`] + }) + + await expect(execute(module, { ...levelOne, seekPermission: false })).rejects.toThrow( + /seekPermission/ + ) + await expect(execute(module, levelOne)).rejects.toThrow(/denied/) + expect(authorize).toHaveBeenCalledTimes(1) + expect(derivePrivateKey).not.toHaveBeenCalled() + }) + + it('uses the privileged deriver and forwards the existing privileged reason', async () => { + const regular = new CachedKeyDeriver(new PrivateKey(67)) + const privileged = new CachedKeyDeriver(new PrivateKey(71)) + const authorize = jest.fn(async () => true) + const privilegedKeyDeriver = jest.fn(async () => privileged) + const module = new EcpmPermissionModule({ + keyDeriver: regular, + privilegedKeyDeriver, + authorize + }) + const original = point(9) + + const regularResult = await execute(module, requestArgs(original)) + const privilegedResult = await execute( + module, + requestArgs(original, 'apply', { + privileged: true, + privilegedReason: 'Protect the private card mask' + }) + ) + + expect(privilegedResult).not.toEqual(regularResult) + expect(privilegedKeyDeriver).toHaveBeenCalledWith('Protect the private card mask') + expect(authorize).toHaveBeenCalledWith( + expect.objectContaining({ + privileged: true, + privilegedReason: 'Protect the private card mask' + }) + ) + }) + + it('scopes cached privileged grants to the exact approved reason', async () => { + const privileged = new CachedKeyDeriver(new PrivateKey(72)) + const authorize = jest.fn(async () => true) + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(73)), + authorize, + privilegedKeyDeriver: async () => privileged + }) + const privilegedArgs = (privilegedReason: string): GetPublicKeyArgs => + requestArgs(point(10), 'apply', { privileged: true, privilegedReason }) + + await execute(module, privilegedArgs('Protect the private card mask')) + await execute(module, privilegedArgs('Protect the private card mask')) + await execute(module, privilegedArgs('Protect the shuffle nonce')) + + expect(authorize).toHaveBeenCalledTimes(2) + expect(authorize).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ privilegedReason: 'Protect the private card mask' }) + ) + expect(authorize).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ privilegedReason: 'Protect the shuffle nonce' }) + ) + }) + + it('does not share pending privileged authorization across different reasons', async () => { + const resolvers: Array<(approved: boolean) => void> = [] + const authorize = jest.fn( + async () => + await new Promise(resolve => { + resolvers.push(resolve) + }) + ) + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(74)), + authorize, + privilegedKeyDeriver: async () => new CachedKeyDeriver(new PrivateKey(75)) + }) + const first = observe( + execute( + module, + requestArgs(point(11), 'apply', { + privileged: true, + privilegedReason: 'Protect the private card mask' + }) + ) + ) + const second = observe( + execute( + module, + requestArgs(point(11), 'apply', { + privileged: true, + privilegedReason: 'Protect the shuffle nonce' + }) + ) + ) + + expect(authorize).toHaveBeenCalledTimes(2) + expect(resolvers).toHaveLength(2) + resolvers[0]!(true) + resolvers[1]!(true) + const outcomes = await Promise.all([first, second]) + expect(outcomes.every(outcome => outcome.ok)).toBe(true) + }) + + it.each<[string, RegExp]>([ + ['createSignature', /not permitted/], + ['encrypt', /not permitted/] + ])('rejects unrelated method %s in the ECPM key namespace', async (method, error) => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(73)) + }) + await expect(execute(module, requestArgs(point(2)), method)).rejects.toThrow(error) + }) + + it.each<[Partial, RegExp]>([ + [{ identityKey: true }, /identityKey/], + [{ forSelf: true }, /forSelf/], + [{ keyID: '' }, /keyID/], + [{ keyID: 'x'.repeat(801) }, /800 bytes/], + [{ privileged: true }, /privilegedReason/], + [{ privileged: true, privilegedReason: 'bad' }, /between 5 and 50/] + ])('rejects conflicting or invalid existing getPublicKey arguments', async (override, error) => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(79)) + }) + await expect(execute(module, requestArgs(point(2), 'apply', override))).rejects.toThrow(error) + }) + + it.each([ + 'p ecpm apply missing', + `p ecpm change ${point(2)} mental poker deal`, + `p ecpm apply ${point(2).toUpperCase()} mental poker deal`, + `p ecpm apply ${point(2)} Bad Protocol`, + `p ecpm apply ${point(2)} bad spacing`, + `p ecpm apply ${point(2)} poker protocol`, + `p ecpm apply ${point(2)} abcd`, + `p ecpm apply ${point(2)} ${'a'.repeat(274)}` + ])('rejects malformed module protocol %s', async protocolName => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(83)) + }) + await expect( + execute(module, requestArgs(point(2), 'apply', { protocolID: [0, protocolName] })) + ).rejects.toThrow() + }) + + it('rejects an ECPM dispatch envelope above 354 bytes', async () => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(84)) + }) + const protocolName = `p ecpm remove ${point(2)} ${'a'.repeat(274)}` + + expect(protocolName).toHaveLength(355) + await expect( + execute(module, requestArgs(point(2), 'remove', { protocolID: [0, protocolName] })) + ).rejects.toThrow('ECPM: outer protocol ID exceeds 354 bytes') + }) + + it('rejects the non-canonical x coordinate before the SDK parser can reduce it', async () => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(89)) + }) + const nonCanonical = `02${'ff'.repeat(32)}` + await expect(execute(module, requestArgs(nonCanonical, 'apply'))).rejects.toThrow( + /canonical field element/ + ) + }) + + it('rejects compressed encodings that do not identify a curve point', async () => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(91)) + }) + const undecodable = `02${'00'.repeat(32)}` + await expect(execute(module, requestArgs(undecodable, 'apply'))).rejects.toThrow( + 'ECPM: point could not be decoded' + ) + }) + + it('rejects a malformed counterparty before key derivation', async () => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(92)) + }) + await expect( + execute(module, requestArgs(point(2), 'apply', { counterparty: 'not a key' })) + ).rejects.toThrow('ECPM: expected a lowercase 33-byte compressed secp256k1 point') + }) + + it('rejects non-string originators and distinguishes malformed request fields', async () => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(93)) + }) + await expect( + module.handleRequest( + { method: 'getPublicKey', args: requestArgs(point(2)), originator: 42 as never }, + async () => undefined + ) + ).rejects.toThrow('ECPM: originator is required') + await expect( + module.handleRequest( + { method: 'getPublicKey', args: 'invalid' as never, originator: 'poker.example' }, + async () => undefined + ) + ).rejects.toThrow('ECPM: getPublicKey arguments must be an object') + await expect( + execute( + module, + requestArgs(point(2), 'apply', { + protocolID: [0, 'valid protocol', 'extra'] as never + }) + ) + ).rejects.toThrow('ECPM: protocolID is required') + await expect( + execute(module, requestArgs(point(2), 'apply', { protocolID: [0, 42 as never] })) + ).rejects.toThrow('ECPM: invalid protocolID') + }) + + it('rejects the field-prime x boundary and uppercase counterparties', async () => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(94)) + }) + const fieldPrime = curve.p.toString(16).padStart(64, '0') + await expect( + execute(module, requestArgs(`02${fieldPrime}` as PubKeyHex, 'apply')) + ).rejects.toThrow('ECPM: x is not a canonical field element') + await expect( + execute(module, requestArgs(point(2), 'apply', { counterparty: point(3).toUpperCase() })) + ).rejects.toThrow('ECPM: expected a lowercase 33-byte compressed secp256k1 point') + }) + + it('fails closed when authorization or privileged derivation is unavailable', async () => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(97)) + }) + const levelOne = requestArgs(point(2), 'apply', { + protocolID: [1, `p ecpm apply ${point(2)} mental poker deal`] + }) + + await expect(execute(module, levelOne)).rejects.toThrow(/authorization handler/) + await expect( + execute( + module, + requestArgs(point(2), 'apply', { + privileged: true, + privilegedReason: 'Use protected poker key' + }) + ) + ).rejects.toThrow(/authorization handler/) + }) + + it('fails closed when a privileged key provider is missing or invalid', async () => { + const ordinary = new CachedKeyDeriver(new PrivateKey(98)) + const args = requestArgs(point(2), 'apply', { + privileged: true, + privilegedReason: 'Use protected poker key' + }) + const withoutProvider = new EcpmPermissionModule({ + keyDeriver: ordinary, + authorize: async () => true + }) + const invalidProvider = new EcpmPermissionModule({ + keyDeriver: ordinary, + authorize: async () => true, + privilegedKeyDeriver: async () => null as never + }) + + await expect(execute(withoutProvider, args)).rejects.toThrow(/unavailable/) + await expect(execute(invalidProvider, args)).rejects.toThrow(/invalid deriver/) + }) + + it.each([ + [null, /arguments must be an object/], + [[], /arguments must be an object/], + [{ keyID: 'key' }, /protocolID is required/], + [{ protocolID: [3, 'p ecpm apply invalid value'], keyID: 'key' }, /invalid protocolID/] + ])('rejects malformed argument structure %#', async (args, error) => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(99)) + }) + await expect( + module.handleRequest( + { method: 'getPublicKey', args: args as never, originator: 'poker.example' }, + async () => undefined + ) + ).rejects.toThrow(error) + }) + + it('rejects an absent originator and an invalid counterparty type', async () => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(100)) + }) + await expect(execute(module, requestArgs(point(2)), 'getPublicKey', '')).rejects.toThrow( + /originator/ + ) + await expect( + execute(module, requestArgs(point(2), 'apply', { counterparty: 42 as never })) + ).rejects.toThrow(/counterparty/) + }) + + it('validates constructor options', () => { + expect(() => new EcpmPermissionModule({ keyDeriver: {} as never })).toThrow(/keyDeriver/) + expect( + () => + new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(101)), + authorizationTTL: 0 + }) + ).toThrow(/authorizationTTL/) + }) +}) diff --git a/packages/wallet/ecpm-permission-module/src/index.ts b/packages/wallet/ecpm-permission-module/src/index.ts new file mode 100644 index 000000000..2fe0b05eb --- /dev/null +++ b/packages/wallet/ecpm-permission-module/src/index.ts @@ -0,0 +1,15 @@ +import { EcpmPermissionModule } from './EcpmPermissionModule.js' +import type { EcpmPermissionModuleOptions } from './types.js' + +export { EcpmPermissionModule } from './EcpmPermissionModule.js' +export type { + EcpmAuthorizationHandler, + EcpmAuthorizationRequest, + EcpmKeyDeriver, + EcpmOperation, + EcpmPermissionModuleOptions, + EcpmPrivilegedKeyDeriver +} from './types.js' + +export const createEcpmModule = (options: EcpmPermissionModuleOptions): EcpmPermissionModule => + new EcpmPermissionModule(options) diff --git a/packages/wallet/ecpm-permission-module/src/types.ts b/packages/wallet/ecpm-permission-module/src/types.ts new file mode 100644 index 000000000..c31cae232 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/src/types.ts @@ -0,0 +1,59 @@ +import type { + GetPublicKeyArgs, + KeyDeriverApi, + PrivateKey, + PubKeyHex, + SecurityLevel, + WalletProtocol +} from '@bsv/sdk' + +export type EcpmOperation = 'apply' | 'remove' + +export type EcpmKeyDeriver = Pick + +export type EcpmPrivilegedKeyDeriver = (reason: string) => EcpmKeyDeriver | Promise + +export interface EcpmAuthorizationRequest { + originator: string + securityLevel: SecurityLevel + logicalProtocolID: string + keyID: string + counterparty: PubKeyHex | 'self' | 'anyone' + privileged: boolean + privilegedReason?: string + operation: EcpmOperation + point: PubKeyHex +} + +export type EcpmAuthorizationHandler = ( + request: EcpmAuthorizationRequest +) => boolean | Promise + +export interface EcpmPermissionModuleOptions { + /** Derives ordinary BRC-42/43 keys for this wallet. */ + keyDeriver: EcpmKeyDeriver + /** Retrieves a privileged deriver only after the supplied reason is authorized. */ + privilegedKeyDeriver?: EcpmPrivilegedKeyDeriver + /** Required for security levels 1/2 and every privileged request. */ + authorize?: EcpmAuthorizationHandler + /** Duration of a successful protocol grant. Defaults to five minutes. */ + authorizationTTL?: number +} + +export interface ParsedEcpmRequest { + args: GetPublicKeyArgs + operation: EcpmOperation + point: PubKeyHex + logicalProtocolID: string + derivationProtocolID: WalletProtocol + keyID: string + counterparty: PubKeyHex | 'self' | 'anyone' + privileged: boolean + privilegedReason?: string +} + +export interface EcpmMultiplyInput { + point: PubKeyHex + derivedKey: PrivateKey + operation: EcpmOperation +} diff --git a/packages/wallet/ecpm-permission-module/tsconfig.build.json b/packages/wallet/ecpm-permission-module/tsconfig.build.json new file mode 100644 index 000000000..7a930fe26 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "incremental": false, + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/__tests__/**"] +} diff --git a/packages/wallet/ecpm-permission-module/tsconfig.json b/packages/wallet/ecpm-permission-module/tsconfig.json new file mode 100644 index 000000000..67abe61e6 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../config/typescript/dual-runtime.json", + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "strict": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/wallet/ecpm-permission-module/tsconfig.typecheck.json b/packages/wallet/ecpm-permission-module/tsconfig.typecheck.json new file mode 100644 index 000000000..7c4072bd3 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/tsconfig.typecheck.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "incremental": false, + "noEmit": true, + "types": ["jest", "node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index 1c260f36b..74e3fce63 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,6 +6,13 @@ attention to changes that materially alter behavior or extend functionality. ## wallet-toolbox (unreleased) +- Extend the BRC-98/99/111 permission-module interface with an optional semantic + `handleRequest` hook. A module can now return a conforming BRC-100 result + directly or invoke the underlying wallet operation at most once, while + existing `onRequest`/`onResponse` transformation modules remain compatible. + The companion `@bsv/ecpm-permission-module` uses this hook to implement + `p ecpm` point multiplication without adding a BRC-100 method or wire call. + - Report `listOutputs` `totalOutputs` as the size of the whole result set on every page, in both the IndexedDB and Knex storage providers. A short final page previously returned only that page's length, so a client paging a large @@ -14,10 +21,10 @@ attention to changes that materially alter behavior or extend functionality. no longer discards the total it already computed. Full pages, first pages, and empty result sets are unchanged, so no consumer migration is required. `balanceAndUtxos` now terminates from page progress instead of relying on the - former collapsing total, preventing a zero-progress loop after the final page. - The reviewed Vite raw-size ceiling advances by 500 bytes to 1,607,500, - covering the hosted Linux measurement of 1,607,015 bytes; the Vite compressed - and esbuild ceilings remain unchanged. + former collapsing total, preventing a zero-progress loop after the final page. + The reviewed Vite raw-size ceiling advances by 500 bytes to 1,607,500, + covering the hosted Linux measurement of 1,607,015 bytes; the Vite compressed + and esbuild ceilings remain unchanged. - Serialize typed AtomicBEEF and competing BEEF in wallet review errors as portable JSON arrays, keeping HTTP and relay error recovery compatible with diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 14e4eefd1..839bcea80 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -31,6 +31,15 @@ broadcast, so permission approval does not inherit network-broadcast latency. The funding planner prefers settled change and uses queued permission ancestry only as a last resort, keeping the application path fast without hiding funds. +Permission modules may transform calls with `onRequest` and `onResponse`, or +own a P-scheme's semantics with the optional `handleRequest(request, next)` +hook. A semantic handler can return the normal BRC-100 result directly; if it +needs the underlying wallet operation, `next` is guarded so it can be invoked +at most once. Existing transformation-only modules remain compatible. The +standalone `@bsv/ecpm-permission-module` demonstrates this extension by +implementing `p ecpm` point multiplication through `getPublicKey`, without a +new BRC-100 method or wire message. + Immediate actions prefer completed, then unproven, then sending change. A pathological settled plan is compared with pending alternatives by exact serialized BEEF plus transaction bytes; queued ancestry is used only when it is diff --git a/packages/wallet/wallet-toolbox/client/package.json b/packages/wallet/wallet-toolbox/client/package.json index 1f45b7414..e838cbc2e 100644 --- a/packages/wallet/wallet-toolbox/client/package.json +++ b/packages/wallet/wallet-toolbox/client/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-toolbox-client", - "version": "2.10.4", + "version": "2.11.0", "type": "module", "sideEffects": false, "engines": { diff --git a/packages/wallet/wallet-toolbox/client/platform-budget.json b/packages/wallet/wallet-toolbox/client/platform-budget.json index 234715215..b5e15c344 100644 --- a/packages/wallet/wallet-toolbox/client/platform-budget.json +++ b/packages/wallet/wallet-toolbox/client/platform-budget.json @@ -3,13 +3,13 @@ "maximumBytes": { "vite": { "raw": 1607500, - "gzip": 378800, + "gzip": 379000, "brotli": 297000 }, "esbuild": { "raw": 1253000, "gzip": 345500, - "brotli": 277300 + "brotli": 277500 } } } diff --git a/packages/wallet/wallet-toolbox/mobile/package.json b/packages/wallet/wallet-toolbox/mobile/package.json index b41142ec1..108bd2da3 100644 --- a/packages/wallet/wallet-toolbox/mobile/package.json +++ b/packages/wallet/wallet-toolbox/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-toolbox-mobile", - "version": "2.10.4", + "version": "2.11.0", "type": "module", "sideEffects": false, "engines": { diff --git a/packages/wallet/wallet-toolbox/mobile/platform-budget.json b/packages/wallet/wallet-toolbox/mobile/platform-budget.json index ed26d710e..7ecfab0dd 100644 --- a/packages/wallet/wallet-toolbox/mobile/platform-budget.json +++ b/packages/wallet/wallet-toolbox/mobile/platform-budget.json @@ -7,7 +7,7 @@ "brotli": 360000 }, "hermes": { - "raw": 3367000, + "raw": 3367500, "gzip": 1366000, "brotli": 1070000 } diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index 6468e1c21..5383f4b40 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-toolbox", - "version": "2.10.4", + "version": "2.11.0", "sideEffects": false, "type": "commonjs", "engines": { diff --git a/packages/wallet/wallet-toolbox/src/WalletPermissionsManager.ts b/packages/wallet/wallet-toolbox/src/WalletPermissionsManager.ts index 311ebdeb0..75cf9a590 100644 --- a/packages/wallet/wallet-toolbox/src/WalletPermissionsManager.ts +++ b/packages/wallet/wallet-toolbox/src/WalletPermissionsManager.ts @@ -67,11 +67,35 @@ export type LineItemType = 'input' | 'output' | 'fee' /** Security level for DPACP protocol permissions. */ export type SecurityLevel = 0 | 1 | 2 +/** A wallet request routed to a BRC-98/99/111 permission module. */ +export interface PermissionsModuleRequest { + method: string + args: object + originator: string +} + +/** + * Invokes the underlying BRC-100 method with module-transformed arguments. + * A semantic module can omit this call and return its own conforming result. + */ +export type PermissionsModuleNext = (args: object) => Promise + /** * A permissions module handles request/response transformation for a specific P-protocol or P-basket scheme under BRC-98/99. * Modules are registered in the config mapped by their scheme ID. */ export interface PermissionsModule { + /** + * Optionally owns the complete execution of a P-module request. + * + * This is the semantic-extension hook for schemes whose behavior cannot be + * expressed by argument and response transformation alone. The handler may + * call `next` once to invoke the underlying BRC-100 method, or return a result + * directly. When present, `onRequest` and `onResponse` are not invoked for the + * delegated request. + */ + handleRequest?: (req: PermissionsModuleRequest, next: PermissionsModuleNext) => Promise + /** * Transforms the request before it's passed to the underlying wallet. * Can check and enforce permissions, throw errors, or modify any arguments as needed prior to invocation. @@ -79,7 +103,7 @@ export interface PermissionsModule { * @param req - The incoming request with method, args, and originator * @returns Transformed arguments that will be passed to the underlying wallet */ - onRequest: (req: { method: string; args: object; originator: string }) => Promise<{ args: object }> + onRequest: (req: PermissionsModuleRequest) => Promise<{ args: object }> /** * Transforms the response from the underlying wallet before returning to caller. @@ -657,12 +681,20 @@ export class WalletPermissionsManager implements WalletInterface { throw new Error(`Unsupported P-module scheme: p ${schemeID}`) } + const request = { method, args, originator } + if (module.handleRequest != null) { + let nextCalled = false + return (await module.handleRequest(request, async transformedArgs => { + if (nextCalled) { + throw new Error(`P-module p ${schemeID} called its underlying wallet operation more than once`) + } + nextCalled = true + return await underlyingCall(transformedArgs, originator) + })) as T + } + // Transform request with module - const transformedReq = await module.onRequest({ - method, - args, - originator - }) + const transformedReq = await module.onRequest(request) // Call underlying method with transformed request const results = await underlyingCall(transformedReq.args, originator) diff --git a/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.pmodules.test.ts b/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.pmodules.test.ts index d583c776b..72fc23f87 100644 --- a/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.pmodules.test.ts +++ b/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.pmodules.test.ts @@ -947,4 +947,93 @@ describe('WalletPermissionsManager - Permission Module Support', () => { expect(testModule.onRequest).not.toHaveBeenCalled() }) }) + + describe('P-Module Semantic Handlers', () => { + it('allows a module to return a wallet result without invoking the underlying method', async () => { + const testModule: PermissionsModule = { + handleRequest: jest.fn(async req => { + expect(req.method).toBe('getPublicKey') + expect(req.originator).toBe('app.com') + return { publicKey: `02${'11'.repeat(32)}` } + }), + onRequest: jest.fn(async req => ({ args: req.args })), + onResponse: jest.fn(async result => result) + } + const manager = new WalletPermissionsManager(underlying, 'admin.com', { + permissionModules: { semantic: testModule } + }) + + const result = await manager.getPublicKey( + { + protocolID: [2, `p semantic apply 02${'22'.repeat(32)} example protocol`], + keyID: 'key 1' + }, + 'app.com' + ) + + expect(result).toEqual({ publicKey: `02${'11'.repeat(32)}` }) + expect(underlying.getPublicKey).not.toHaveBeenCalled() + expect(testModule.onRequest).not.toHaveBeenCalled() + expect(testModule.onResponse).not.toHaveBeenCalled() + }) + + it('allows a semantic handler to forward transformed arguments through next', async () => { + const transformedProtocol: [2, string] = [2, 'ordinary protocol'] + const testModule: PermissionsModule = { + handleRequest: jest.fn(async (req, next) => { + const result = await next({ + ...req.args, + protocolID: transformedProtocol + }) + return { ...(result as object), handled: true } + }), + onRequest: jest.fn(async req => ({ args: req.args })), + onResponse: jest.fn(async result => result) + } + const manager = new WalletPermissionsManager(underlying, 'admin.com', { + permissionModules: { semantic: testModule } + }) + underlying.getPublicKey.mockResolvedValue({ publicKey: `03${'33'.repeat(32)}` }) + + const result = await manager.getPublicKey( + { + protocolID: [2, 'p semantic forwarded request'], + keyID: 'key 1' + }, + 'app.com' + ) + + expect(underlying.getPublicKey).toHaveBeenCalledWith( + expect.objectContaining({ protocolID: transformedProtocol }), + 'app.com' + ) + expect(result).toEqual({ publicKey: `03${'33'.repeat(32)}`, handled: true }) + }) + + it('rejects a semantic handler that invokes the underlying operation twice', async () => { + const testModule: PermissionsModule = { + handleRequest: async (request, next) => { + await next(request.args) + return await next(request.args) + }, + onRequest: jest.fn(async request => ({ args: request.args })), + onResponse: jest.fn(async result => result) + } + const manager = new WalletPermissionsManager(underlying, 'admin.com', { + permissionModules: { semantic: testModule } + }) + underlying.getPublicKey.mockResolvedValue({ publicKey: `03${'44'.repeat(32)}` }) + + await expect( + manager.getPublicKey( + { + protocolID: [0, 'p semantic example request'], + keyID: '1' + }, + 'app.com' + ) + ).rejects.toThrow(/more than once/) + expect(underlying.getPublicKey).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea9c99f40..16a652fd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1525,6 +1525,45 @@ importers: specifier: npm:@typescript/typescript6@6.0.2 version: '@typescript/typescript6@6.0.2' + packages/wallet/ecpm-permission-module: + devDependencies: + '@bsv/sdk': + specifier: workspace:^ + version: link:../../sdk + '@bsv/wallet-toolbox-client': + specifier: workspace:^ + version: link:../wallet-toolbox/client + '@jest/globals': + specifier: ^30.4.1 + version: 30.4.1 + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 + '@types/node': + specifier: ^26.1.2 + version: 26.1.2 + '@typescript/native': + specifier: npm:typescript@7.0.2 + version: typescript@7.0.2 + fast-check: + specifier: ^4.9.0 + version: 4.9.0 + jest: + specifier: ^30.4.2 + version: 30.4.2(@types/node@26.1.2)(ts-node@10.9.2(@types/node@26.1.2)(@typescript/typescript6@6.0.2)) + oxlint: + specifier: ^1.76.0 + version: 1.76.0 + ts-jest: + specifier: ^29.4.12 + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(@typescript/typescript6@6.0.2)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.4.2(@types/node@26.1.2)(ts-node@10.9.2(@types/node@26.1.2)(@typescript/typescript6@6.0.2))) + tsdown: + specifier: 0.22.14 + version: 0.22.14(@arethetypeswrong/core@0.18.5)(@typescript/typescript6@6.0.2)(publint@0.3.22)(tsx@4.23.1) + typescript: + specifier: npm:@typescript/typescript6@6.0.2 + version: '@typescript/typescript6@6.0.2' + packages/wallet/ts-wallet-relay: dependencies: ws: diff --git a/scripts/contributor-policy.test.mjs b/scripts/contributor-policy.test.mjs index fa55f2473..e28596113 100644 --- a/scripts/contributor-policy.test.mjs +++ b/scripts/contributor-policy.test.mjs @@ -14,7 +14,7 @@ import { test('current contributor and agent policy is uniform across the governed stack', () => { const result = evaluateContributorPolicy() assert.deepEqual(result.errors, []) - assert.equal(result.summary.scopedProjectsAndServices, 45) + assert.equal(result.summary.scopedProjectsAndServices, 46) assert.equal(result.summary.consolidatedLegacyAgentFiles, 31) assert.equal(result.summary.historicalGitHubFiles, 49) assert.equal(result.summary.retiredPackageContributionFiles, 8) diff --git a/scripts/package-documentation.mjs b/scripts/package-documentation.mjs index 111582287..52726e8bd 100644 --- a/scripts/package-documentation.mjs +++ b/scripts/package-documentation.mjs @@ -219,7 +219,7 @@ tags: [reference, packages, api, declarations, migrations, release-notes] # Package API, Declarations, and Migration Ledger -This page is generated from all 32 public manifests, package documentation, and +This page is generated from all ${packages.length} public manifests, package documentation, and \`governance/package-release-notes.json\`. It records source candidates without publishing them. CI rejects a version change unless its release classification, summary, and migration guidance are updated at the same time. diff --git a/scripts/package-documentation.test.mjs b/scripts/package-documentation.test.mjs index 6f218a48f..f2287cb98 100644 --- a/scripts/package-documentation.test.mjs +++ b/scripts/package-documentation.test.mjs @@ -5,8 +5,8 @@ import { loadPackageDocumentation, renderPackageDocumentation } from './package- test('package API and migration ledger covers every public package', async () => { const model = await loadPackageDocumentation() assert.deepEqual(model.errors, []) - assert.equal(model.packages.length, 32) - assert.equal(model.packages.filter(pkg => pkg.releaseType !== 'none').length, 32) + assert.equal(model.packages.length, 33) + assert.equal(model.packages.filter(pkg => pkg.releaseType !== 'none').length, 33) assert.ok(model.packages.every(pkg => pkg.docsPath?.startsWith('docs/packages/'))) const rendered = renderPackageDocumentation(model) diff --git a/scripts/package-license-policy.test.mjs b/scripts/package-license-policy.test.mjs index 76f31f46a..91fdd7254 100644 --- a/scripts/package-license-policy.test.mjs +++ b/scripts/package-license-policy.test.mjs @@ -25,7 +25,7 @@ test('all package projects use the exact current Open BSV license', () => { assert.equal(LICENSE_FILE, 'LICENSE.txt') assert.equal(LICENSE_DECLARATION, 'SEE LICENSE IN LICENSE.txt') assert.equal(OCI_LICENSE_REFERENCE, 'LicenseRef-Open-BSV-License-6') - assert.equal(discoverPackageManifests().length, 48) + assert.equal(discoverPackageManifests().length, 49) assert.deepEqual(validatePackageLicenses(), []) }) diff --git a/scripts/package-release-artifacts.mjs b/scripts/package-release-artifacts.mjs index ad4192d6c..401455caa 100644 --- a/scripts/package-release-artifacts.mjs +++ b/scripts/package-release-artifacts.mjs @@ -166,13 +166,16 @@ function governedProjects(registry) { return registry.projects.filter(project => project.release === 'npm-oidc') } -async function loadGovernedProjects() { +export async function loadGovernedProjects(expectedCount) { const registry = await readJson( path.join(REPOSITORY_ROOT, 'governance/repository-health/projects.json') ) const projects = governedProjects(registry) - if (projects.length !== 32) { - throw new Error(`expected 32 governed npm packages, found ${projects.length}`) + if (!Number.isSafeInteger(expectedCount) || expectedCount < 1) { + throw new Error(`invalid governed npm package count ${JSON.stringify(expectedCount)}`) + } + if (projects.length !== expectedCount) { + throw new Error(`expected ${expectedCount} governed npm packages, found ${projects.length}`) } return await Promise.all( projects.map(async project => { @@ -999,7 +1002,8 @@ async function stageArtifacts(arguments_) { if (filter && requested.length > 0) { throw new Error('--filter and --package cannot be used together') } - const [governed, policy] = await Promise.all([loadGovernedProjects(), readJson(POLICY_PATH)]) + const policy = await readJson(POLICY_PATH) + const governed = await loadGovernedProjects(policy.publicPackageCount) const source = await sourceMetadata(policy) await ensureEmptyOutputDirectory(outputDirectory) const governedNames = new Set(governed.map(project => project.name)) @@ -1283,11 +1287,11 @@ async function verifyArtifacts(manifestOption) { const manifestPath = path.resolve(REPOSITORY_ROOT, manifestOption) const releaseRoot = path.dirname(manifestPath) const manifest = await readJson(manifestPath) - const [{ stdout: currentCommit }, governed, policy] = await Promise.all([ + const [{ stdout: currentCommit }, policy] = await Promise.all([ run('git', ['rev-parse', 'HEAD'], { cwd: REPOSITORY_ROOT }), - loadGovernedProjects(), readJson(POLICY_PATH) ]) + const governed = await loadGovernedProjects(policy.publicPackageCount) validateManifestEnvelope(manifest, currentCommit.trim(), policy) const context = { diff --git a/scripts/package-release-artifacts.test.mjs b/scripts/package-release-artifacts.test.mjs index f87b60496..f8a7700b3 100644 --- a/scripts/package-release-artifacts.test.mjs +++ b/scripts/package-release-artifacts.test.mjs @@ -9,6 +9,7 @@ import { canonicalizePackedManifest, createLicenseInventory, deterministicUuid, + loadGovernedProjects, mergeCycloneDxDocuments, prepareSbomManifest, removeInjectedRootDependencies, @@ -292,6 +293,19 @@ test('release staging requires the exact governed build runtime', () => { ) }) +test('release staging derives its governed package count from supply-chain policy', async () => { + const policy = JSON.parse(fs.readFileSync(POLICY_PATH, 'utf8')) + const projects = await loadGovernedProjects(policy.publicPackageCount) + + assert.equal(projects.length, policy.publicPackageCount) + await assert.rejects( + loadGovernedProjects(policy.publicPackageCount - 1), + new RegExp( + `expected ${policy.publicPackageCount - 1} governed npm packages, found ${policy.publicPackageCount}` + ) + ) +}) + test('aggregate CycloneDX retains package roots and dependency relationships', () => { const records = [ { diff --git a/scripts/patch-coverage.mjs b/scripts/patch-coverage.mjs index 5b41bb25d..67638a364 100644 --- a/scripts/patch-coverage.mjs +++ b/scripts/patch-coverage.mjs @@ -48,6 +48,7 @@ const EXCLUDED_SOURCE_PATTERNS = [ /packages\/wallet\/wallet-toolbox\/src\/storage\/index\.mobile\.ts$/, /packages\/helpers\/simple\/src\/core\/types\.ts$/, /packages\/wallet\/btms\/src\/types\.ts$/, + /packages\/wallet\/ecpm-permission-module\/src\/types\.ts$/, // CHIRP's package entry point is a pure re-export barrel and its types module // emits declarations only. The executable CLI remains instrumented and is // intentionally not part of this exact exclusion. diff --git a/scripts/patch-coverage.test.mjs b/scripts/patch-coverage.test.mjs index 77c0d53cf..ac32769bb 100644 --- a/scripts/patch-coverage.test.mjs +++ b/scripts/patch-coverage.test.mjs @@ -115,6 +115,9 @@ diff --git a/packages/helpers/simple/src/core/types.ts b/packages/helpers/simple diff --git a/packages/wallet/btms/src/types.ts b/packages/wallet/btms/src/types.ts +++ b/packages/wallet/btms/src/types.ts @@ -0,0 +1,12 @@ +diff --git a/packages/wallet/ecpm-permission-module/src/types.ts b/packages/wallet/ecpm-permission-module/src/types.ts ++++ b/packages/wallet/ecpm-permission-module/src/types.ts +@@ -0,0 +1,12 @@ diff --git a/packages/network/chirp/src/index.ts b/packages/network/chirp/src/index.ts +++ b/packages/network/chirp/src/index.ts @@ -0,0 +1,12 @@ diff --git a/scripts/repository-health.test.mjs b/scripts/repository-health.test.mjs index 893a44308..bd4592d78 100644 --- a/scripts/repository-health.test.mjs +++ b/scripts/repository-health.test.mjs @@ -40,11 +40,11 @@ test('lint exclusion parsing rejects authored tests and benchmarks without backt ) }) -test('workspace discovery exactly matches the 39-project registry', () => { +test('workspace discovery exactly matches the 40-project registry', () => { const discovered = discoverWorkspaceProjects() - assert.equal(discovered.length, 39) - assert.equal(discovered.filter(project => project.manifest.private !== true).length, 32) + assert.equal(discovered.length, 40) + assert.equal(discovered.filter(project => project.manifest.private !== true).length, 33) assert.deepEqual( discovered.map(project => project.path), [...projects.projects].map(project => project.path).sort() @@ -67,7 +67,7 @@ test('workspace discovery exactly matches the 39-project registry', () => { test('every checked-in first-party package manifest uses the current Association name', () => { const manifests = discoverPackageManifests() - assert.equal(manifests.length, 48) + assert.equal(manifests.length, 49) assert.deepEqual(validatePackageAuthorIdentity(manifests), []) assert.ok(manifests.every(({ manifest }) => manifest.author === PACKAGE_AUTHOR)) @@ -77,11 +77,11 @@ test('every checked-in first-party package manifest uses the current Association }) test('current repository health controls and ratchet are internally consistent', () => { - const result = evaluateRepositoryHealth({ today: '2026-08-24' }) + const result = evaluateRepositoryHealth({ today: '2026-08-30' }) assert.deepEqual(result.errors, []) - assert.equal(result.projects.length, 39) - assert.equal(result.publicPackages, 32) + assert.equal(result.projects.length, 40) + assert.equal(result.publicPackages, 33) assert.equal(result.findings.length, 0) }) @@ -228,7 +228,7 @@ test('every public package declares supported runtime and canonical support meta project => project.manifest.private !== true ) - assert.equal(publicPackages.length, 32) + assert.equal(publicPackages.length, 33) for (const project of publicPackages) { assert.equal( project.manifest.engines?.node, @@ -282,7 +282,7 @@ test('every public package declares supported runtime and canonical support meta test('every public package has canonical, machine-verified consumer profiles', () => { const publicProjects = projects.projects.filter(project => project.release === 'npm-oidc') - assert.equal(publicProjects.length, 32) + assert.equal(publicProjects.length, 33) assert.ok(publicProjects.every(project => project.consumerProfiles.length > 0)) assert.deepEqual( [...new Set(publicProjects.flatMap(project => project.consumerProfiles))].sort(), diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index 1db40c06a..0532915b1 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -32,11 +32,11 @@ test('current required, manual, live, resource, and conformance tests are govern assert.deepEqual(result.errors, []) assert.equal(result.summary.requiredDirectSkips, 2) - assert.equal(result.summary.propertySuites, 31) - assert.equal(result.summary.propertyPackages, 29) + assert.equal(result.summary.propertySuites, 32) + assert.equal(result.summary.propertyPackages, 30) assert.equal(result.summary.propertyExcludedPackages, 6) - assert.equal(result.summary.propertyClassifiedPackages, 35) - assert.equal(result.summary.mutationTargets, 31) + assert.equal(result.summary.propertyClassifiedPackages, 36) + assert.equal(result.summary.mutationTargets, 32) assert.equal(result.summary.manualAndLiveFiles, 32) assert.equal(result.summary.walletManualSuites, 30) assert.equal(result.summary.conformanceSkipFiles, 19) diff --git a/scripts/typescript-toolchain.test.mjs b/scripts/typescript-toolchain.test.mjs index 52998dc11..6cfa427c0 100644 --- a/scripts/typescript-toolchain.test.mjs +++ b/scripts/typescript-toolchain.test.mjs @@ -26,7 +26,7 @@ const governedManifest = { test('all tracked TypeScript projects use the governed side-by-side toolchain', () => { const report = inspectTypeScriptToolchain() - assert.equal(report.governed, 45) + assert.equal(report.governed, 46) assert.equal(report.codegen, 1) assert.ok(report.configurations > 100) assert.equal(report.profiles, 9)