From 48a2aa2d1ddba0a993a70315d6653def3c48ac38 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sun, 26 Jul 2026 10:56:18 -0700 Subject: [PATCH 1/2] Harden and standardize Message Box stack --- conformance/generated/messaging/models.py | 12 +- .../generated/messaging/types.gen.d.ts | 157 ++- conformance/generated/messaging/types.gen.go | 123 +- docs/infrastructure/message-box-server.md | 120 +- docs/packages/messaging/message-box-client.md | 189 +-- docs/specs/message-box-http.md | 197 ++- .../repository-health/contract-baseline.json | 14 +- infra/message-box-server/.env.example | 3 +- infra/message-box-server/.prettierignore | 8 + infra/message-box-server/.vscode/launch.json | 67 - infra/message-box-server/AGENTS.md | 201 +-- infra/message-box-server/BASELINE.md | 61 +- infra/message-box-server/DEPLOYING.md | 236 ++-- infra/message-box-server/Dockerfile | 10 +- infra/message-box-server/README.md | 525 +++----- .../jest.config.integration.ts | 26 - infra/message-box-server/jest.config.mjs | 30 +- infra/message-box-server/jest.config.ts | 35 - infra/message-box-server/jest.setup.mjs | 26 +- infra/message-box-server/knexfile.ts | 7 +- infra/message-box-server/nginx.conf | 42 - infra/message-box-server/package-lock.json | 468 +------ infra/message-box-server/package.json | 20 +- infra/message-box-server/scripts/build.js | 19 - .../src/__tests/rateLimitPolicy.test.ts | 42 +- infra/message-box-server/src/app.ts | 99 +- infra/message-box-server/src/compose.ts | 121 +- .../src/config/__tests/runtime.test.ts | 18 + .../message-box-server/src/config/firebase.ts | 138 +- .../message-box-server/src/config/runtime.ts | 32 + infra/message-box-server/src/context.ts | 18 +- infra/message-box-server/src/index.ts | 99 +- .../2022-12-28-001-initial-migration.ts | 8 +- .../migrations/2023-01-17-messages-update.ts | 8 +- .../2024-03-05-001-messageID-upgrade.ts | 12 +- ...2025-01-31-001-notification-permissions.ts | 4 +- .../2025-01-31-002-device-registrations.ts | 2 +- ...2026-07-26-001-message-permission-scope.ts | 46 + .../2026-07-26-002-list-query-indexes.ts | 31 + .../__tests/listQueryIndexes.test.ts | 57 + .../__tests/messagePermissionScope.test.ts | 64 + .../routes/__tests/acknowledgeMessage.test.ts | 103 +- .../src/routes/__tests/deviceRoutes.test.ts | 182 +++ .../src/routes/__tests/getQuote.test.ts | 77 +- .../src/routes/__tests/health.test.ts | 53 + .../src/routes/__tests/listMessages.test.ts | 204 ++- .../__tests/permissionValidation.test.ts | 95 ++ .../src/routes/__tests/sendMessage.test.ts | 189 +-- .../src/routes/acknowledgeMessage.ts | 54 +- infra/message-box-server/src/routes/health.ts | 32 + infra/message-box-server/src/routes/index.ts | 8 +- .../src/routes/listDevices.ts | 40 +- .../src/routes/listMessages.ts | 131 +- .../src/routes/permissions/getPermission.ts | 53 +- .../src/routes/permissions/getQuote.ts | 60 +- .../src/routes/permissions/index.ts | 7 +- .../src/routes/permissions/listPermissions.ts | 64 +- .../src/routes/permissions/setPermission.ts | 33 +- .../src/routes/registerDevice.ts | 82 +- .../src/routes/sendMessage.ts | 89 +- infra/message-box-server/src/runtimeDeps.ts | 7 +- .../src/security/webSocketPolicy.test.ts | 40 +- .../src/security/webSocketPolicy.ts | 18 +- infra/message-box-server/src/swagger.ts | 31 +- infra/message-box-server/src/telemetry.ts | 150 +-- .../src/types/mock-knex.d.ts | 2 +- infra/message-box-server/src/utils/logger.ts | 32 +- .../src/utils/messagePermissions.ts | 51 +- .../src/utils/sendFCMNotification.ts | 62 +- infra/message-box-server/tsconfig.json | 18 +- .../.github/workflows/push.yaml | 73 - .../message-box-client/.vscode/settings.json | 3 - .../messaging/message-box-client/AGENTS.md | 257 ++-- .../messaging/message-box-client/BASELINE.md | 61 +- .../messaging/message-box-client/README.md | 1092 +++------------ .../message-box-client/browser-budget.json | 42 + .../jest.config.integration.ts | 15 +- .../message-box-client/jest.config.ts | 27 +- packages/messaging/message-box-client/mod.ts | 1 + .../messaging/message-box-client/package.json | 54 +- .../scripts/remove-node-https-fallback.cjs | 15 + .../scripts/run-integration.mjs | 54 + .../src/MessageBoxClient.ts | 1192 ++++++++++------- .../message-box-client/src/PeerPayClient.ts | 321 +++-- .../message-box-client/src/PeerTokenClient.ts | 266 ++-- .../src/RemittanceAdapter.ts | 48 +- .../src/TokenSettlementAdapter.ts | 25 +- .../message-box-client/src/Utils/logger.ts | 10 +- .../src/__tests/MessageBoxClient.test.ts | 490 ++++--- .../MessageBoxClientPermissions.test.ts | 226 ++++ .../PeerPayClientRequestIntegration.test.ts | 103 +- .../src/__tests/PeerPayClientUnit.test.ts | 153 ++- .../src/__tests/PeerTokenClientUnit.test.ts | 106 +- .../src/__tests/RemittanceAdapter.test.ts | 88 +- .../src/__tests/host.test.ts | 52 + .../integration/integrationEncrypted.test.ts | 20 +- .../integration/integrationHTTP.test.ts | 9 +- .../integration/integrationOverlay.test.ts | 9 +- .../__tests/integration/integrationWS.test.ts | 6 +- .../src/__tests/integration/testServer.ts | 68 - .../messaging/message-box-client/src/host.ts | 110 ++ .../messaging/message-box-client/src/types.ts | 18 +- .../src/types/permissions.ts | 4 +- .../messaging/message-box-client/ts2md.json | 7 - .../message-box-client/tsconfig.base.json | 33 +- .../message-box-client/tsconfig.cjs.json | 10 - .../message-box-client/tsconfig.esm.json | 8 - .../message-box-client/tsconfig.json | 18 +- .../tsconfig.typecheck.json | 9 + .../message-box-client/tsconfig.types.json | 11 - .../message-box-client/tsdown.config.ts | 13 + .../message-box-client/webpack.config.js | 7 +- pnpm-lock.yaml | 175 +-- specs/messaging/message-box-http.yaml | 392 ++++-- 114 files changed, 5884 insertions(+), 5179 deletions(-) create mode 100644 infra/message-box-server/.prettierignore delete mode 100644 infra/message-box-server/.vscode/launch.json delete mode 100644 infra/message-box-server/jest.config.integration.ts delete mode 100644 infra/message-box-server/jest.config.ts delete mode 100644 infra/message-box-server/nginx.conf delete mode 100644 infra/message-box-server/scripts/build.js create mode 100644 infra/message-box-server/src/config/__tests/runtime.test.ts create mode 100644 infra/message-box-server/src/config/runtime.ts create mode 100644 infra/message-box-server/src/migrations/2026-07-26-001-message-permission-scope.ts create mode 100644 infra/message-box-server/src/migrations/2026-07-26-002-list-query-indexes.ts create mode 100644 infra/message-box-server/src/migrations/__tests/listQueryIndexes.test.ts create mode 100644 infra/message-box-server/src/migrations/__tests/messagePermissionScope.test.ts create mode 100644 infra/message-box-server/src/routes/__tests/deviceRoutes.test.ts create mode 100644 infra/message-box-server/src/routes/__tests/health.test.ts create mode 100644 infra/message-box-server/src/routes/__tests/permissionValidation.test.ts create mode 100644 infra/message-box-server/src/routes/health.ts delete mode 100644 packages/messaging/message-box-client/.github/workflows/push.yaml delete mode 100644 packages/messaging/message-box-client/.vscode/settings.json create mode 100644 packages/messaging/message-box-client/browser-budget.json create mode 100644 packages/messaging/message-box-client/scripts/remove-node-https-fallback.cjs create mode 100644 packages/messaging/message-box-client/scripts/run-integration.mjs create mode 100644 packages/messaging/message-box-client/src/__tests/MessageBoxClientPermissions.test.ts create mode 100644 packages/messaging/message-box-client/src/__tests/host.test.ts delete mode 100644 packages/messaging/message-box-client/src/__tests/integration/testServer.ts create mode 100644 packages/messaging/message-box-client/src/host.ts delete mode 100644 packages/messaging/message-box-client/ts2md.json delete mode 100644 packages/messaging/message-box-client/tsconfig.cjs.json delete mode 100644 packages/messaging/message-box-client/tsconfig.esm.json create mode 100644 packages/messaging/message-box-client/tsconfig.typecheck.json delete mode 100644 packages/messaging/message-box-client/tsconfig.types.json create mode 100644 packages/messaging/message-box-client/tsdown.config.ts diff --git a/conformance/generated/messaging/models.py b/conformance/generated/messaging/models.py index 59c8aa9db..a6f014795 100644 --- a/conformance/generated/messaging/models.py +++ b/conformance/generated/messaging/models.py @@ -47,18 +47,22 @@ class MessageObject(BaseModel): None, description='Preferred plural form. Takes precedence over `recipient` when both are present.', ) - messageBox: str = Field( + messageBox: constr(min_length=1, max_length=128) = Field( ..., description='Named message box (e.g. `payment_inbox`, `notifications`).', examples=['payment_inbox'], ) - messageId: str | list[str] = Field( + messageId: ( + constr(min_length=1, max_length=256) + | list[constr(min_length=1, max_length=256)] + ) = Field( ..., description='Unique identifier(s) for the message(s) — typically an HMAC. When\n`recipients` has more than one entry, `messageId` must be an array\nof the same length with one ID per recipient.\n', examples=['xyz123'], ) - body: str | dict[str, Any] = Field( - ..., description='Message payload. Strings and JSON objects are both accepted.' + body: str = Field( + ..., + description='Serialized message payload. The official client serializes object\nbodies before sending them.\n', ) diff --git a/conformance/generated/messaging/types.gen.d.ts b/conformance/generated/messaging/types.gen.d.ts index 23a1cab0b..83d2948ca 100644 --- a/conformance/generated/messaging/types.gen.d.ts +++ b/conformance/generated/messaging/types.gen.d.ts @@ -4,6 +4,40 @@ */ export interface paths { + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Report process liveness */ + get: operations["health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ready": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Report database readiness */ + get: operations["readiness"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/sendMessage": { parameters: { query?: never; @@ -55,9 +89,10 @@ export interface paths { put?: never; /** * Retrieve messages from a named message box - * @description Returns all stored messages belonging to the authenticated identity in the - * specified message box. Returns an empty array if the box does not exist. - * Message bodies are always returned as strings (JSON objects are stringified). + * @description Returns one deterministic, bounded page of stored messages belonging + * to the authenticated identity in the specified message box. Returns an + * empty array if the box does not exist. Message bodies are always + * returned as strings (JSON objects are stringified). */ post: operations["listMessages"]; delete?: never; @@ -99,10 +134,10 @@ export interface paths { put?: never; /** * Register a device for FCM push notifications - * @description Stores or updates an FCM token for the authenticated identity. If the - * token already exists it is updated (identity key, device ID, platform, - * last_used timestamp). The FCM token is unique-indexed; upsert semantics - * are applied via ON CONFLICT MERGE. + * @description Stores or refreshes an FCM token for the authenticated identity. A + * token already owned by another identity cannot be reassigned through + * this route. Re-registering a token for the same identity updates its + * device metadata and last-used timestamp. */ post: operations["registerDevice"]; delete?: never; @@ -120,7 +155,7 @@ export interface paths { }; /** * List registered devices for the authenticated identity - * @description Returns all device registrations for the authenticated user, sorted by + * @description Returns one bounded page of device registrations for the authenticated user, sorted by * `updated_at` descending. FCM tokens are truncated for security (last 10 * characters prefixed with "..."). */ @@ -170,8 +205,7 @@ export interface paths { * Get the message permission for a sender/box combination * @description Retrieves the permission record for a specific sender + message box pair * owned by the authenticated user. If `sender` is omitted, returns the - * box-wide default. Returns `permission: undefined` (null in JSON) when no - * record is set. + * box-wide default. Returns `permission: null` when no record is set. */ get: operations["getPermission"]; put?: never; @@ -190,12 +224,12 @@ export interface paths { cookie?: never; }; /** - * List all message permissions for the authenticated user - * @description Returns all permission records owned by the authenticated identity with - * optional filtering and pagination. Results are ordered by - * `(message_box ASC, sender ASC NULLS FIRST, created_at DESC/ASC)`. - * Box-wide defaults (sender = null) appear before sender-specific entries - * within each message box. + * List message permissions for the authenticated user + * @description Returns one bounded page of permission records owned by the + * authenticated identity, with optional filtering. Results are ordered by + * `(message_box ASC, normalized sender scope ASC, created_at DESC/ASC)`. + * Box-wide defaults (sender = null) use the empty normalized scope and + * therefore appear before sender-specific entries within each message box. */ get: operations["listPermissions"]; put?: never; @@ -325,10 +359,11 @@ export interface components { * @example xyz123 */ messageId: string | string[]; - /** @description Message payload. Strings and JSON objects are both accepted. */ - body: string | { - [key: string]: unknown; - }; + /** + * @description Serialized message payload. The official client serializes object + * bodies before sending them. + */ + body: string; }; /** @description A single output inside a Payment describing how to route satoshis. */ PaymentOutput: { @@ -429,6 +464,63 @@ export interface components { } export type $defs = Record; export interface operations { + health: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Process is alive. */ + 200: { + headers: { + /** @description Always `no-store`. */ + "Cache-Control"?: string; + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {string} */ + status: "ok"; + }; + }; + }; + }; + }; + readiness: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Required dependencies are ready. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {string} */ + status: "ready"; + }; + }; + }; + /** @description A required dependency is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; sendMessage: { parameters: { query?: never; @@ -537,6 +629,10 @@ export interface operations { * @example payment_inbox */ messageBox: string; + /** @default 1000 */ + limit?: number; + /** @default 0 */ + offset?: number; }; }; }; @@ -551,6 +647,9 @@ export interface operations { /** @enum {string} */ status: "success"; messages: components["schemas"]["StoredMessage"][]; + limit: number; + offset: number; + hasMore: boolean; }; }; }; @@ -712,6 +811,15 @@ export interface operations { "application/json": components["schemas"]["ErrorResponse"]; }; }; + /** @description Device token is already registered to another identity. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; /** @description Internal server error (`ERR_DATABASE_ERROR`, `ERR_INTERNAL`). */ 500: { headers: { @@ -725,7 +833,10 @@ export interface operations { }; listDevices: { parameters: { - query?: never; + query?: { + limit?: number; + offset?: number; + }; header?: never; path?: never; cookie?: never; @@ -741,6 +852,8 @@ export interface operations { "application/json": { /** @enum {string} */ status: "success"; + limit: number; + offset: number; devices: components["schemas"]["RegisteredDevice"][]; }; }; @@ -856,7 +969,7 @@ export interface operations { /** @enum {string} */ status: "success"; description: string; - /** @description The permission record, or null/undefined if not set. */ + /** @description The permission record, or null if not set. */ permission?: components["schemas"]["PermissionRecord"] | null; }; }; diff --git a/conformance/generated/messaging/types.gen.go b/conformance/generated/messaging/types.gen.go index 5605b6c30..405f2bcff 100644 --- a/conformance/generated/messaging/types.gen.go +++ b/conformance/generated/messaging/types.gen.go @@ -118,6 +118,21 @@ func (e ListDevices200JSONResponseBodyStatus) Valid() bool { } } +// Defines values for Health200JSONResponseBodyStatus. +const ( + Ok Health200JSONResponseBodyStatus = "ok" +) + +// Valid indicates whether the value is a known member of the Health200JSONResponseBodyStatus enum. +func (e Health200JSONResponseBodyStatus) Valid() bool { + switch e { + case Ok: + return true + default: + return false + } +} + // Defines values for ListMessages200JSONResponseBodyStatus. const ( ListMessages200JSONResponseBodyStatusSuccess ListMessages200JSONResponseBodyStatus = "success" @@ -226,6 +241,21 @@ func (e SetPermission200JSONResponseBodyStatus) Valid() bool { } } +// Defines values for Readiness200JSONResponseBodyStatus. +const ( + Ready Readiness200JSONResponseBodyStatus = "ready" +) + +// Valid indicates whether the value is a known member of the Readiness200JSONResponseBodyStatus enum. +func (e Readiness200JSONResponseBodyStatus) Valid() bool { + switch e { + case Ready: + return true + default: + return false + } +} + // Defines values for RegisterDeviceJSONBodyPlatform. const ( RegisterDeviceJSONBodyPlatformAndroid RegisterDeviceJSONBodyPlatform = "android" @@ -315,8 +345,9 @@ type ErrorResponseStatus string // recipients are provided, `messageId` must be an array of the same length // with one ID per recipient (same order). type MessageObject struct { - // Body Message payload. Strings and JSON objects are both accepted. - Body MessageObject_Body `json:"body"` + // Body Serialized message payload. The official client serializes object + // bodies before sending them. + Body string `json:"body"` // MessageBox Named message box (e.g. `payment_inbox`, `notifications`). // @@ -340,17 +371,6 @@ type MessageObject struct { Recipients *[]PubKeyHex `json:"recipients,omitempty"` } -// MessageObjectBody0 defines model for MessageObject.Body.0. -type MessageObjectBody0 = string - -// MessageObjectBody1 defines model for MessageObject.Body.1. -type MessageObjectBody1 map[string]interface{} - -// MessageObject_Body Message payload. Strings and JSON objects are both accepted. -type MessageObject_Body struct { - union json.RawMessage -} - // MessageObjectMessageId0 defines model for MessageObject.MessageId.0. type MessageObjectMessageId0 = string @@ -502,15 +522,27 @@ type AcknowledgeMessageJSONBody struct { // AcknowledgeMessage200JSONResponseBodyStatus defines parameters for AcknowledgeMessage. type AcknowledgeMessage200JSONResponseBodyStatus string +// ListDevicesParams defines parameters for ListDevices. +type ListDevicesParams struct { + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` +} + // ListDevices200JSONResponseBodyStatus defines parameters for ListDevices. type ListDevices200JSONResponseBodyStatus string +// Health200JSONResponseBodyStatus defines parameters for Health. +type Health200JSONResponseBodyStatus string + // ListMessagesJSONBody defines parameters for ListMessages. type ListMessagesJSONBody struct { + Limit *int `json:"limit,omitempty"` + // MessageBox Name of the message box to retrieve messages from. // // Examples: payment_inbox MessageBox string `json:"messageBox"` + Offset *int `json:"offset,omitempty"` } // ListMessages200JSONResponseBodyStatus defines parameters for ListMessages. @@ -632,6 +664,9 @@ type SetPermissionJSONBody struct { // SetPermission200JSONResponseBodyStatus defines parameters for SetPermission. type SetPermission200JSONResponseBodyStatus string +// Readiness200JSONResponseBodyStatus defines parameters for Readiness. +type Readiness200JSONResponseBodyStatus string + // RegisterDeviceJSONBody defines parameters for RegisterDevice. type RegisterDeviceJSONBody struct { // DeviceId Optional caller-supplied device identifier. @@ -684,68 +719,6 @@ type RegisterDeviceJSONRequestBody RegisterDeviceJSONBody // SendMessageJSONRequestBody defines body for SendMessage for application/json ContentType. type SendMessageJSONRequestBody SendMessageJSONBody -// AsMessageObjectBody0 returns the union data inside the MessageObject_Body as a MessageObjectBody0 -func (t MessageObject_Body) AsMessageObjectBody0() (MessageObjectBody0, error) { - var body MessageObjectBody0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromMessageObjectBody0 overwrites any union data inside the MessageObject_Body as the provided MessageObjectBody0 -func (t *MessageObject_Body) FromMessageObjectBody0(v MessageObjectBody0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeMessageObjectBody0 performs a merge with any union data inside the MessageObject_Body, using the provided MessageObjectBody0 -func (t *MessageObject_Body) MergeMessageObjectBody0(v MessageObjectBody0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsMessageObjectBody1 returns the union data inside the MessageObject_Body as a MessageObjectBody1 -func (t MessageObject_Body) AsMessageObjectBody1() (MessageObjectBody1, error) { - var body MessageObjectBody1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromMessageObjectBody1 overwrites any union data inside the MessageObject_Body as the provided MessageObjectBody1 -func (t *MessageObject_Body) FromMessageObjectBody1(v MessageObjectBody1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeMessageObjectBody1 performs a merge with any union data inside the MessageObject_Body, using the provided MessageObjectBody1 -func (t *MessageObject_Body) MergeMessageObjectBody1(v MessageObjectBody1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t MessageObject_Body) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *MessageObject_Body) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - // AsMessageObjectMessageId0 returns the union data inside the MessageObject_MessageId as a MessageObjectMessageId0 func (t MessageObject_MessageId) AsMessageObjectMessageId0() (MessageObjectMessageId0, error) { var body MessageObjectMessageId0 diff --git a/docs/infrastructure/message-box-server.md b/docs/infrastructure/message-box-server.md index e26dd225e..fac115f68 100644 --- a/docs/infrastructure/message-box-server.md +++ b/docs/infrastructure/message-box-server.md @@ -1,10 +1,10 @@ --- id: infra-message-box-server -title: "Message-box Server" +title: 'Message-box Server' kind: infra -version: "1.1.14" -last_updated: "2026-07-25" -last_verified: "2026-07-25" +version: '1.1.14' +last_updated: '2026-07-25' +last_verified: '2026-07-25' review_cadence_days: 30 status: stable tags: [messaging, overlay, store-and-forward, authentication] @@ -29,26 +29,28 @@ Clients connect with identity-based authentication, send and receive messages th ## Dependencies -| Type | Requirement | -|------|-------------| -| Database | MySQL 8.0 via mysql2 | -| External services | Wallet Storage (WALLET_STORAGE_URL), Firebase Admin SDK (optional) | +| Type | Requirement | +| ----------------- | ------------------------------------------------------------------------------------------------------------- | +| Database | MySQL 8.0 via mysql2 | +| External services | Wallet Storage (WALLET_STORAGE_URL), Firebase Admin SDK (optional) | | ts-stack packages | @bsv/sdk, @bsv/auth-express-middleware, @bsv/authsocket, @bsv/payment-express-middleware, @bsv/wallet-toolbox | ## HTTP endpoints -| Method | Path | Purpose | -|--------|------|---------| -| POST | /sendMessage | Send encrypted message to recipient (authenticated) | -| POST | /listMessages | List all unacknowledged messages in box (authenticated) | -| POST | /acknowledgeMessage | Mark messages as read/delete them (authenticated) | -| POST | /registerDevice | Register a push-notification device for the authenticated identity | -| GET | /devices | List the authenticated identity's devices with redacted tokens | -| GET | /permissions/get | Read a recipient permission | -| GET | /permissions/list | List recipient permissions | -| GET | /permissions/quote | Quote up to 100 recipients | -| POST | /permissions/set | Set an authenticated recipient permission | -| GET | /docs, /openapi.json | Public API documentation | +| Method | Path | Purpose | +| ------ | -------------------- | ------------------------------------------------------------------ | +| POST | /sendMessage | Send encrypted message to recipient (authenticated) | +| POST | /listMessages | List all unacknowledged messages in box (authenticated) | +| POST | /acknowledgeMessage | Mark messages as read/delete them (authenticated) | +| POST | /registerDevice | Register a push-notification device for the authenticated identity | +| GET | /devices | List the authenticated identity's devices with redacted tokens | +| GET | /permissions/get | Read a recipient permission | +| GET | /permissions/list | List recipient permissions | +| GET | /permissions/quote | Quote up to 100 recipients | +| POST | /permissions/set | Set an authenticated recipient permission | +| GET | /health | Public process liveness | +| GET | /ready | Public database readiness | +| GET | /docs, /openapi.json | Public API documentation | ## WebSocket endpoints @@ -60,28 +62,28 @@ Clients connect with identity-based authentication, send and receive messages th ## Configuration (env vars) -| Variable | Required | Description | -|----------|----------|-------------| -| NODE_ENV | No | `development`, `staging`, or `production` | -| PORT | No | Express port (default 3000 prod, 8080 dev) | -| HTTP_PORT | No | Alternative port alias | -| HOSTING_DOMAIN | No | Public domain for overlay advertisement (e.g., `http://localhost:8080`) | -| SERVER_PRIVATE_KEY | Yes | 256-bit hex private key for server identity and auth signing | -| ROUTING_PREFIX | No | Optional path prefix for all routes (e.g., `/api`) | -| ENABLE_WEBSOCKETS | No | Set to `'true'` to enable real-time messaging (default true) | -| LOGGING_ENABLED | No | Set to `'true'` for verbose debug logging | -| WALLET_STORAGE_URL | No | URL of wallet storage service (e.g., `https://store-us-1.bsvb.tech`) | -| KNEX_DB_CLIENT | No | Database client (default: `mysql`) | -| KNEX_DB_CONNECTION | Yes | JSON connection config: `{"host":"localhost","port":3306,"user":"root","password":"...","database":"messagebox-backend"}` | -| MIGRATE_KEY | No | Optional key to authorize migration operations | -| ENABLE_FIREBASE | No | Set to `'true'` to enable Firebase push notifications | -| FIREBASE_PROJECT_ID | No | GCP project ID for Firebase | -| FIREBASE_SERVICE_ACCOUNT_JSON | No | Firebase service account JSON (inline) | -| FIREBASE_SERVICE_ACCOUNT_PATH | No | Path to Firebase service account JSON file | -| MESSAGE_BOX_CORS_MODE | No | `public` (default), `allowlist`, or `disabled` | -| MESSAGE_BOX_CORS_ALLOWED_ORIGINS | No | Exact comma-separated origins in allowlist mode | -| MESSAGE_BOX_MAX_BODY_BYTES | No | HTTP JSON ceiling (default 4194304) | -| MESSAGE_BOX_WEBSOCKET_MAX_BODY_BYTES | No | WebSocket payload ceiling (default 1048576) | +| Variable | Required | Description | +| ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------- | +| NODE_ENV | No | `development`, `staging`, or `production` | +| 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`) | +| SERVER_PRIVATE_KEY | Yes | 256-bit hex private key for server identity and auth signing | +| ROUTING_PREFIX | No | Optional path prefix for all routes (e.g., `/api`) | +| ENABLE_WEBSOCKETS | No | Set to `'true'` to enable real-time messaging (default true) | +| LOGGING_ENABLED | No | Set to `'true'` for verbose debug logging | +| WALLET_STORAGE_URL | No | URL of wallet storage service (e.g., `https://store-us-1.bsvb.tech`) | +| KNEX_DB_CLIENT | No | Database client (default: `mysql`) | +| KNEX_DB_CONNECTION | Yes | JSON connection config: `{"host":"localhost","port":3306,"user":"root","password":"...","database":"messagebox-backend"}` | +| MIGRATE_KEY | No | Optional key to authorize migration operations | +| ENABLE_FIREBASE | No | Set to `'true'` to enable Firebase push notifications | +| FIREBASE_PROJECT_ID | No | GCP project ID for Firebase | +| FIREBASE_SERVICE_ACCOUNT_JSON | No | Firebase service account JSON (inline) | +| FIREBASE_SERVICE_ACCOUNT_PATH | No | Path to Firebase service account JSON file | +| MESSAGE_BOX_CORS_MODE | No | `public` (default), `allowlist`, or `disabled` | +| MESSAGE_BOX_CORS_ALLOWED_ORIGINS | No | Exact comma-separated origins in allowlist mode | +| MESSAGE_BOX_MAX_BODY_BYTES | No | HTTP JSON ceiling (default 4194304) | +| MESSAGE_BOX_WEBSOCKET_MAX_BODY_BYTES | No | WebSocket payload ceiling (default 1048576) | See [Public Service Edge Security](service-edge-security.md#message-box) for rate, timeout, WebSocket authorization, CORS/CSP, and error controls. @@ -111,44 +113,49 @@ npm start # Build Docker image docker build -t messagebox:latest . -# Run with nginx reverse proxy on port 8080 +# Run the service directly on port 8080 docker run -d \ -e NODE_ENV=production \ + -e PORT=8080 \ -e SERVER_PRIVATE_KEY= \ -e KNEX_DB_CONNECTION='{"host":"mysql","port":3306,"user":"root","password":"...","database":"messagebox-backend"}' \ -e ENABLE_WEBSOCKETS=true \ -p 8080:8080 \ messagebox:latest -# Or use docker-compose (app runs on 3000, nginx on 8080) +# Or use docker-compose docker compose up -d ``` The multi-stage Dockerfile compiles TypeScript for a minimal production -runtime. Nginx proxies to the app with a 4 MiB body ceiling and bounded -header/body/send/upstream timeouts. +runtime. The Node process serves HTTP and WebSocket traffic directly; deploy a +trusted platform ingress or load balancer in front when required. ## Migrations -Knex migrations run automatically on server startup with 5-second delay. To run manually: +Knex migrations run automatically at server startup. After building, they can +also be run manually: ```bash -npm run migrate +npx --no-install knex --knexfile out/knexfile.js migrate:latest --env production ``` Migrations tracked in `src/migrations/`: + - `2022-12-28-001-initial-migration.ts` – Core messages table with identity keys - `2023-01-17-messages-update.ts` – Payload storage updates - `2024-03-05-001-messageID-upgrade.ts` – MessageID uniqueness constraints - `2025-01-31-001-notification-permissions.ts` – Firebase notification permissions - `2025-01-31-002-device-registrations.ts` – Device registration tracking +- `2026-07-26-001-message-permission-scope.ts` – Enforce one box-wide or sender-specific permission per scope ## Health checks -No explicit health endpoint; monitor via: -- **WebSocket connectivity** – Test authenticated WebSocket handshake -- **HTTP endpoints** – POST /listMessages returns 200 for authenticated clients -- **Database** – Query availability of messages table +- `GET /health` reports 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 + enabled. ## Spec conformance @@ -159,7 +166,7 @@ No explicit health endpoint; monitor via: ## Integration with ts-stack -- Clients connect via `@bsv/messagebox-client` library +- Clients connect via `@bsv/message-box-client` - Uses Wallet Storage for key derivation from SERVER_PRIVATE_KEY - Advertises MessageBox capabilities to overlay nodes via SHIP protocol using HOSTING_DOMAIN - Optionally enforces BRC-100 payment verification on message send via `@bsv/payment-express-middleware` @@ -170,9 +177,12 @@ No explicit health endpoint; monitor via: - Database indexing: Ensure indexes on identity keys + messageBox for high-volume deployments - Firebase setup: Requires valid service account JSON; Firebase push disabled if misconfigured - Private key generation: Must be 256-bit hex; asymmetric key used for mutual auth signing -- Migration delays: Auto-run happens with 5-second delay; use MIGRATE_KEY to authorize if needed +- Migration failures prevent the process from accepting traffic; deployments + should still gate traffic on `/ready`. ## Source - [GitHub](https://github.com/bsv-blockchain/ts-stack/tree/main/infra/message-box-server) -- [npm package](https://npmjs.com/package/@bsv/messagebox-server) + +The service package is private and is distributed as a container, not as a +public npm package. diff --git a/docs/packages/messaging/message-box-client.md b/docs/packages/messaging/message-box-client.md index ffd0912b8..5bc863479 100644 --- a/docs/packages/messaging/message-box-client.md +++ b/docs/packages/messaging/message-box-client.md @@ -4,158 +4,93 @@ title: "@bsv/message-box-client" kind: package domain: messaging version: "2.2.2" -source_repo: "bsv-blockchain/message-box-client" -source_commit: "unknown" -last_updated: "2026-07-24" -last_verified: "2026-07-24" +source_repo: "bsv-blockchain/ts-stack" +source_commit: "d4c98d06ca24d5e03722079b9e5b31df32ee9b68" +last_updated: "2026-07-26" +last_verified: "2026-07-26" review_cadence_days: 30 npm: "https://www.npmjs.com/package/@bsv/message-box-client" -repo: "https://github.com/bsv-blockchain/message-box-client" +repo: "https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/message-box-client" status: stable -tags: [messaging, message-box, authrite] +tags: [messaging, message-box, brc-103, brc-29] --- # @bsv/message-box-client -> Toolkit for peer-to-peer messaging and Bitcoin SV payments via store-and-forward architecture. Provides `MessageBoxClient` (message inbox management) and `PeerPayClient` (higher-level P2P payments) leveraging BRC-103 mutual authentication and BRC-29 payment derivation. +> Browser- and Node-compatible authenticated store-and-forward messaging, +> live WebSockets, peer payments, token settlement, permissions, quotes, and +> push-device registration. ## Install ```bash -npm install @bsv/message-box-client +npm install @bsv/message-box-client @bsv/sdk ``` +`@bsv/sdk` is a required peer. Node.js 22 or newer is supported. + ## Quick start -```typescript +```ts import { MessageBoxClient } from '@bsv/message-box-client' import { WalletClient } from '@bsv/sdk' -const myWallet = new WalletClient() -const msgBoxClient = new MessageBoxClient({ - host: 'https://message-box-us-1.bsvb.tech', - walletClient: myWallet -}) - -await msgBoxClient.init() - -// Send message to John -await msgBoxClient.sendMessage({ - recipient: '022600d2ef37d123fdcac7d25d7a464ada7acd3fb65a0daf85412140ee20884311', - messageBox: 'demo_inbox', - body: 'Hello John!' -}) - -// List messages -const messages = await msgBoxClient.listMessages({ messageBox: 'demo_inbox' }) -console.log(messages[0].body) - -// Acknowledge (delete) -await msgBoxClient.acknowledgeMessage({ - messageIds: messages.map(msg => msg.messageId.toString()) -}) -``` - -## What it provides - -- **MessageBoxClient** — Store-and-forward message client with HTTP and WebSocket support -- **PeerPayClient** — Higher-level P2P payment client with live and HTTP modes -- **BRC-103 authentication** — All requests signed with wallet identity; server verifies sender -- **Message encryption** — AES-256-GCM by default; `skipEncryption` flag disables -- **Ephemeral storage** — Messages deleted once acknowledged by recipient -- **Live WebSocket** — Push notifications via WebSocket; HTTP for polling -- **BRC-29 derivation** — Payment addresses derived from sender+recipient identity keys -- **Overlay discovery** — Discover peers' MessageBox hosts via decentralized overlay service - -## Common patterns - -### Listening for live messages - -```typescript -const msgBoxClient = new MessageBoxClient({ - host: 'https://message-box-us-1.bsvb.tech', - walletClient: myWallet -}) - -await msgBoxClient.initializeConnection() -await msgBoxClient.joinRoom('demo_inbox') - -await msgBoxClient.listenForLiveMessages({ - messageBox: 'demo_inbox', - onMessage: (msg) => console.log('Live:', msg.body) +const wallet = new WalletClient() +const client = new MessageBoxClient({ + walletClient: wallet, + host: 'https://message-box-us-1.bsvb.tech' }) -``` - -### Peer-to-peer payments - -```typescript -import { PeerPayClient } from '@bsv/message-box-client' -const peerPay = new PeerPayClient({ walletClient: myWallet }) - -// Listen for incoming payments -await peerPay.listenForLivePayments({ - onPayment: async (payment) => { - console.log('Received payment:', payment) - await peerPay.acceptPayment(payment) - } +await client.sendMessage({ + recipient: '025706528f0f6894b2ba505007267ccff1133e004452a1f6b72ac716f246216366', + messageBox: 'general_inbox', + body: { text: 'Hello' } }) -// Send 50,000 sats -await peerPay.sendLivePayment({ - recipient: '025706528f0f6894b2ba505007267ccff1133e004452a1f6b72ac716f246216366', - amount: 50000 +const messages = await client.listMessages({ messageBox: 'general_inbox' }) +await client.acknowledgeMessage({ + messageIds: messages.map(message => message.messageId) }) ``` -## Key concepts - -- **Store-and-forward** — Messages posted to named "inboxes" on server; recipient polls or subscribes via WebSocket -- **Ephemeral storage** — Messages deleted once acknowledged by recipient -- **Message encryption** — AES-256-GCM by default; `skipEncryption` flag disables -- **BRC-103 auth** — All requests signed with wallet identity; server verifies sender -- **BRC-29 derivation** — Payment addresses derived from sender+recipient identity keys -- **Overlay network** — Can discover peers' MessageBox hosts via decentralized overlay service -- **Live vs HTTP** — WebSocket for push notifications; HTTP for polling - -## When to use this - -- Building peer-to-peer messaging applications on BSV -- Sending notifications or alerts to Bitcoin identities -- Implementing private communication channels with identity verification -- Creating user-to-user messaging systems with signatures -- Building P2P payment applications with store-and-forward messaging +Initialization is automatic. Call `init()` only when explicit startup control +is useful. -## When NOT to use this - -- Simple text messages without encryption — use plain HTTP instead -- Only one-way notifications without delivery confirmation — consider webhooks -- Real-time chat requiring sub-second latency — use `@bsv/authsocket-client` instead -- Bulk email-style messages — integrate traditional email service instead - -## Spec conformance - -- **BRC-103** (Peer-to-Peer Mutual Authentication): All messages signed/verified -- **BRC-29** (Payment Derivation): Payment address derivation using sender+recipient keys -- **BRC-100** (Wallet interface): Uses standard wallet methods for signing/verifying -- **MessageBox server protocol**: Custom HTTP + WebSocket endpoints for store-and-forward - -## Common pitfalls - -1. **Auto-init default** — `init()` is optional but recommended; first use auto-initializes if not called -2. **Encryption enabled by default** — message bodies are AES-256-GCM encrypted; set `skipEncryption: true` for raw data -3. **Live requires room subscription** — WebSocket requires explicit `joinRoom()` before `listenForLiveMessages()` -4. **Payment rejection reduces amount** — rejecting a payment sends a refund minus 1000 sats -5. **Overlay discovery takes time** — if no explicit host provided, client queries overlay; this takes ~10s per peer - -## Related packages - -- **@bsv/authsocket-client** — Real-time authenticated WebSocket communication -- **@bsv/auth-express-middleware** — HTTP authentication for REST endpoints -- **@bsv/paymail** — Paymail protocol for identity and payment address discovery - -## Reference +## What it provides -- [API reference (TypeDoc)](https://bsv-blockchain.github.io/ts-stack/api/message-box-client/) -- [Source on GitHub](https://github.com/bsv-blockchain/message-box-client) +- `MessageBoxClient` — authenticated HTTP polling and live WebSocket delivery +- encryption through the BRC-100 wallet protocol, enabled by default +- overlay host advertisement and public-HTTPS discovery +- sender-specific and box-wide permissions with fee quotes +- Firebase push-device registration +- `PeerPayClient` — BRC-29 payments, requests, responses, and refunds +- `PeerTokenClient` — token transport through pluggable settlement adapters +- `RemittanceAdapter` — SDK remittance communication integration + +## Security and interoperability + +Configured hosts must be absolute HTTP(S) URLs without credentials, query +strings, or fragments. HTTP is retained for operator-controlled local +development. Untrusted overlay destinations require HTTPS and cannot target +local, private, link-local, reserved, or documentation-only hosts. + +Message Box remains accessible from arbitrary deployed browser origins by +default. Server-side exact-origin allowlists or disabled CORS are operator +opt-ins. CORS and CSP do not replace BRC-103 identity authentication, +recipient-owned boxes, permissions, payment checks, quotas, or end-to-end +message encryption. + +## Distribution and verification + +The package publishes ESM, CommonJS, declarations, source maps, and a UMD +browser bundle. Exact-tarball validation exercises clean ESM/CommonJS +consumers, strict declaration resolution, `publint`, browser bundling through +Vite and esbuild, and bundle budgets. Source and tests are not published. + +## Related + +- [Package README](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/message-box-client#readme) +- [Message Box HTTP API](../../specs/message-box-http.md) +- [Peer-to-peer messaging guide](../../guides/peer-to-peer-messaging.md) +- [Message Box Server](../../infrastructure/message-box-server.md) - [npm](https://www.npmjs.com/package/@bsv/message-box-client) diff --git a/docs/specs/message-box-http.md b/docs/specs/message-box-http.md index 474dbd7f7..9a5f59e39 100644 --- a/docs/specs/message-box-http.md +++ b/docs/specs/message-box-http.md @@ -2,139 +2,124 @@ id: spec-message-box-http title: MessageBox Server HTTP API kind: spec -version: "1.0.0" -last_updated: "2026-04-30" -last_verified: "2026-04-30" +version: '1.0.0' +last_updated: '2026-07-26' +last_verified: '2026-07-26' status: stable -tags: ["spec", "messaging"] +tags: ['spec', 'messaging', 'brc-103'] --- # MessageBox Server HTTP API -> The MessageBox Server provides a store-and-forward REST API for peer-to-peer messaging. Clients send messages to named inboxes, recipients retrieve messages later, and messages are deleted once acknowledged. All requests use BRC-31 mutual authentication so the server knows sender and recipient identity without passwords. - -## At a glance - -| Field | Value | -|---|---| -| Format | OpenAPI 3.1 | -| Version | 1.0.0 | -| Status | stable | -| Implementations | @bsv/message-box-client | - -## What problem this solves - -**Asynchronous peer-to-peer messaging without accounts or servers**. Traditional messaging requires accounts, servers, and complex infrastructure. MessageBox enables store-and-forward: sender encrypts and posts to recipient's inbox, recipient polls or listens via WebSocket, and messages are automatically deleted after reading. Identity is cryptographic (no username/password). - -**Encrypted message storage**. Messages are encrypted with AES-256-GCM before storage. Only the recipient (holder of the private key) can decrypt. The server cannot read message contents. - -**Peer discovery via overlay network**. Clients can discover peers' MessageBox hosts by querying the overlay network (UHRP protocol), enabling dynamic host discovery without DNS or a central directory. - -## Protocol overview - -**Three-step flow** (send → store → retrieve): - -1. **Sender → MessageBox** `POST /messages` - - Recipient public key in body - - Message body (encrypted with AES-256-GCM) - - `x-bsv-auth-*` headers (BRC-31 mutual auth) - - Server stores message in recipient's inbox - -2. **Recipient → MessageBox** `GET /messages/{messageBox}` - - Retrieves all pending messages in their inbox - - `x-bsv-auth-*` headers - - Server returns array of messages - -3. **Recipient → MessageBox** `POST /acknowledge` - - Acknowledges (deletes) messages by ID - - `x-bsv-auth-*` headers - - Server deletes messages - -**Live subscription** (alternative to polling): - -- **Recipient → MessageBox** WebSocket upgrade + BRC-103 handshake -- Server pushes new messages in real-time via WebSocket -- Recipient can `joinRoom(messageBox)` / `listenForMessages()` - -## Key types / endpoints - -| Method | Path | Purpose | Request | Response | -|--------|------|---------|---------|----------| -| POST | `/messages` | Send message | `{ recipient, messageBox, body }` | `{ messageId }` | -| GET | `/messages/{messageBox}` | List messages | (via auth headers) | `[ { messageId, body, sender, timestamp } ]` | -| POST | `/acknowledge` | Delete messages | `{ messageIds: [...] }` | OK | -| GET | `/resolve-host` | Discover peer's MessageBox host | `{ identityKey }` | `{ host }` | -| WebSocket | `/live` | Listen for live messages | BRC-103 handshake + `joinRoom` | Real-time message push | - -## Example: Send encrypted message - -```typescript +> An authenticated store-and-forward API for sending messages to named boxes, +> retrieving them later, and deleting them after acknowledgment. + +## Contract + +| Field | Value | +| -------------- | --------------------------------------------- | +| Artifact | OpenAPI 3.1 | +| Authentication | BRC-103 over the BRC-104 HTTP binding | +| Client | `@bsv/message-box-client` | +| Server | `infra/message-box-server` in this repository | + +Except for health and API-documentation routes, requests use the +`x-bsv-auth-*` header family emitted and verified by the BSV auth middleware. +The authenticated identity is the sender for sends and the owner for list, +acknowledgment, device, and permission operations. + +## Endpoints + +| Method | Path | Authentication | Purpose | +| ------ | --------------------- | -------------- | ------------------------------------------------ | +| GET | `/health` | Public | Process liveness | +| GET | `/ready` | Public | Database readiness without dependency details | +| POST | `/sendMessage` | BRC-103 | Send to one or up to 100 recipients | +| POST | `/listMessages` | BRC-103 | List a bounded page of an identity-owned box | +| POST | `/acknowledgeMessage` | BRC-103 | Delete up to 1,000 identity-owned messages by ID | +| POST | `/registerDevice` | BRC-103 | Register a push-notification device | +| GET | `/devices` | BRC-103 | List registered devices with redacted tokens | +| POST | `/permissions/set` | BRC-103 | Set a sender-specific or box-wide permission | +| GET | `/permissions/get` | BRC-103 | Get a permission | +| GET | `/permissions/list` | BRC-103 | List permissions with pagination | +| GET | `/permissions/quote` | BRC-103 | Quote one or up to 100 recipients | +| GET | `/docs` | Public | Swagger UI | +| GET | `/openapi.json` | Public | Runtime OpenAPI document | + +`ROUTING_PREFIX` may prefix every route in a deployment. + +## Send, retrieve, acknowledge + +```ts import { MessageBoxClient } from '@bsv/message-box-client' import { WalletClient } from '@bsv/sdk' -const wallet = new WalletClient('auto', 'example.com') - -const msgBox = new MessageBoxClient({ - walletClient: wallet, +const client = new MessageBoxClient({ + walletClient: new WalletClient(), host: 'https://message-box-us-1.bsvb.tech' }) -// 1. Send message (auto-encrypted with AES-256-GCM) -await msgBox.sendMessage({ +await client.sendMessage({ recipient: '025706528f0f6894b2ba505007267ccff1133e004452a1f6b72ac716f246216366', messageBox: 'general_inbox', - body: 'Hello! This is encrypted and only you can read it.' + body: 'Hello' +}) + +const messages = await client.listMessages({ messageBox: 'general_inbox' }) +await client.acknowledgeMessage({ + messageIds: messages.map(message => message.messageId) }) ``` -Example: Retrieve and acknowledge messages +The client encrypts message bodies by default. The server persists the opaque +payload together with routing metadata. Acknowledgment deletes only rows owned +by the authenticated recipient. -```typescript -// 2. Retrieve all messages in inbox -const messages = await msgBox.listMessages({ - messageBox: 'general_inbox' -}) +## Live transport -for (const msg of messages) { - console.log(`From ${msg.sender}: ${msg.body}`) -} +Authenticated Socket.IO connections use the same BRC-103 peer identity. +Connections may join only rooms owned by that identity. Live sends reuse the +HTTP handler's validation, permission, payment, deduplication, and persistence +logic; delivery notifications go only to connections authenticated as the +recipient. The client falls back to HTTP if the WebSocket does not acknowledge +a send. -// 3. Acknowledge (delete) messages -await msgBox.acknowledgeMessage({ - messageIds: messages.map(m => m.messageId) -}) -``` +## Permissions and payments -Example: Listen for live messages +Recipient permissions use: -```typescript -// 4. Subscribe to real-time messages -await msgBox.listenForLiveMessages({ - messageBox: 'general_inbox', - onMessage: (msg) => { - console.log('Live message:', msg.body) - } -}) -``` +- `-1` — blocked +- `0` — allowed without recipient payment +- positive integer — required recipient fee in satoshis + +The quote route caps a request at 100 recipients and executes permission +lookups with bounded concurrency. Permission or fee storage failures fail +closed with an internal error; they do not silently grant free delivery. -## Conformance vectors +Message reads are deterministic pages of at most 1,000 records with a bounded +offset and `hasMore` indicator. The client follows those pages with an explicit +100,000-message ceiling, preventing any single database response or accidental +client loop from becoming unbounded. Device and permission listings likewise +use strict, bounded pagination. -There is no standalone MessageBox vector directory in the current conformance corpus. Related portable coverage lives in `conformance/vectors/messaging/brc31/authrite-signature.json`; MessageBox client/server behavior is covered by package tests and the OpenAPI artifact linked below. +## Public-service edge policy -## Implementations in ts-stack +Message Box is intentionally callable from deployed applications, wallet UIs, +mobile webviews, native shells, and unknown future domains. The default browser +policy is credential-free wildcard CORS, including opaque `Origin: null`. +Operators may opt into an exact-origin allowlist or disable CORS. Wildcard +origin is never combined with credentials. -| Package | Notes | -|---------|-------| -| @bsv/message-box-client | Client library for sending/receiving messages; WebSocket support via Socket.IO; BRC-31 auth integration | -| MessageBox Server | Infrastructure repository (not in ts-stack); HTTP + WebSocket endpoints | +CSP applies to served documents such as `/docs`; it is not API access control. +BRC-103 authentication, recipient ownership, permissions, payments, quotas, +request limits, and encryption remain the service's security boundaries. -## Related specs +## Conformance -- [BRC-31 Auth](./brc-31-auth.md) — Mutual authentication for all endpoints -- [AuthSocket](./authsocket.md) — WebSocket implementation of BRC-31 -- [BRC-29 Peer Payment](./brc-29-peer-payment.md) — Payment delivery via message box -- [UHRP](./uhrp.md) — Host discovery overlay protocol +The OpenAPI artifact is code-generated and checked for deterministic drift. +Message Box behavior is covered by client and server tests; there is no +standalone Message Box vector directory in the portable conformance corpus. -## Spec artifact +## Artifact [message-box-http.yaml](https://github.com/bsv-blockchain/ts-stack/blob/main/specs/messaging/message-box-http.yaml) diff --git a/governance/repository-health/contract-baseline.json b/governance/repository-health/contract-baseline.json index b9acf855d..620bfb128 100644 --- a/governance/repository-health/contract-baseline.json +++ b/governance/repository-health/contract-baseline.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "recordedAt": "2026-07-26", - "findingCount": 54, + "findingCount": 51, "findings": [ { "id": ".::missing-script::format:check", @@ -39,18 +39,6 @@ "id": "docs-site::missing-script::test", "message": "Profile documentation-site requires script test" }, - { - "id": "packages/messaging/message-box-client::missing-script::format:check", - "message": "Profile dual-runtime-library requires script format:check" - }, - { - "id": "packages/messaging/message-box-client::missing-script::pack:check", - "message": "Profile dual-runtime-library requires script pack:check" - }, - { - "id": "packages/messaging/message-box-client::missing-script::typecheck", - "message": "Profile dual-runtime-library requires script typecheck" - }, { "id": "packages/middleware/auth-express-middleware::missing-script::format:check", "message": "Profile node-library requires script format:check" diff --git a/infra/message-box-server/.env.example b/infra/message-box-server/.env.example index 68ab9ae42..f1107f5d7 100644 --- a/infra/message-box-server/.env.example +++ b/infra/message-box-server/.env.example @@ -6,7 +6,8 @@ SERVER_PRIVATE_KEY='replace-with-a-dedicated-private-key' # Public protocol access is the default. To restrict browser callers: # MESSAGE_BOX_CORS_MODE=allowlist # MESSAGE_BOX_CORS_ALLOWED_ORIGINS="http://localhost:3000" -# Use MESSAGE_BOX_CORS_MODE=disabled to reject all cross-origin browser calls. +# Use MESSAGE_BOX_CORS_MODE=disabled to emit no CORS headers. Non-browser +# protocol clients remain available; browsers enforce their own origin policy. # MESSAGE_BOX_CROSS_ORIGIN_RESOURCE_POLICY=cross-origin # MESSAGE_BOX_CROSS_ORIGIN_OPENER_POLICY=unsafe-none # MESSAGE_BOX_FRAME_OPTIONS=disabled diff --git a/infra/message-box-server/.prettierignore b/infra/message-box-server/.prettierignore new file mode 100644 index 000000000..f647da229 --- /dev/null +++ b/infra/message-box-server/.prettierignore @@ -0,0 +1,8 @@ +# Canonical shared policies are synchronized byte-for-byte from infra/wab. +src/security/edgePolicy.ts +src/security/rateLimitPolicy.ts + +# Generated build and coverage output. +out/ +coverage/ +node_modules/ diff --git a/infra/message-box-server/.vscode/launch.json b/infra/message-box-server/.vscode/launch.json deleted file mode 100644 index e45aebb43..000000000 --- a/infra/message-box-server/.vscode/launch.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "default", - "type": "node", - "request": "launch", - "skipFiles": [ - "/**" - ], - "program": "${workspaceFolder}/lib/index.js", - "outFiles": [ - "${workspaceFolder}/**/*.js" - ] - }, - { - "name": "Launch", - "type": "node", - "program": "${workspaceFolder}/src/index.ts", - "request": "launch", - "stopOnEntry": false, - "args": [], - "cwd": "${workspaceFolder}/.", - "runtimeExecutable": null, - "runtimeArgs": [ - "--nolazy" - ], - "env": { - "NODE_ENV": "development" - }, - "console": "integratedTerminal", - "sourceMaps": true, - "outFiles": [ - "${workspaceFolder}/**/*.js" - ] - }, - { - "type": "node", - "request": "launch", - "name": "Jest Current Tests", - "program": "${workspaceFolder}/node_modules/.bin/jest", - "args": [ - "--runTestsByPath", - "${relativeFile}" - ], - "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen", - "sourceMaps": true, - "windows": { - "program": "${workspaceFolder}/node_modules/jest/bin/jest" - } - }, - { - "type": "node", - "request": "attach", - "name": "Attach to Node Process", - "port": 9229, - "restart": true, - "skipFiles": ["/**"], - "sourceMaps": true - } -] - -} \ No newline at end of file diff --git a/infra/message-box-server/AGENTS.md b/infra/message-box-server/AGENTS.md index 547b5de8f..45faa144d 100644 --- a/infra/message-box-server/AGENTS.md +++ b/infra/message-box-server/AGENTS.md @@ -1,92 +1,113 @@ -# CLAUDE.md — Message Box Server - -## Purpose -A secure peer-to-peer message routing server for the Bitcoin SV ecosystem. Provides identity-based message delivery, real-time WebSocket communication, and full mutual authentication using BRC-103 signatures. Messages are encrypted and stored until acknowledged, supporting both HTTP and WebSocket transports. - -## Service surface -- **POST /sendMessage** – Send encrypted message to a recipient's message box -- **POST /listMessages** – List all unacknowledged messages in a box (authenticated) -- **POST /acknowledgeMessage** – Mark messages as read/delete them (authenticated) -- **WebSocket** – Real-time authenticated messaging over `@bsv/authsocket` using rooms in format `{identityKey}-{messageBox}` - - Events: `authenticated`, `joinRoom`, `sendMessage`, `leaveRoom` -- **Background jobs** – Firebase push notifications (optional, enabled via ENABLE_FIREBASE) - -## Real deployment -- **Dockerfile** – Multi-stage build: node:20-alpine builder → production runtime with nginx reverse proxy on port 8080 (app runs on 3000, nginx on 8080) -- **docker-compose.yml** – Backend (Node), MySQL 8.0, PHPMyAdmin. MySQL connection pool with health checks -- **knexfile.ts** – MySQL 8.0 via mysql2, default connection string configurable via KNEX_DB_CONNECTION -- **nginx.conf** – HTTP/2 reverse proxy listening on 8080, proxying to localhost:3000, gzip enabled, 1GB max body size -- **Migrations** – Knex migrations stored in `src/migrations/`: - - `2022-12-28-001-initial-migration.ts` – Core messages table with identity keys - - `2023-01-17-messages-update.ts` – Payload storage updates - - `2024-03-05-001-messageID-upgrade.ts` – MessageID uniqueness constraints - - `2025-01-31-001-notification-permissions.ts` – Firebase notification permissions - - `2025-01-31-002-device-registrations.ts` – Device registration tracking - -## Configuration -Environment variables (from `.env.example`): -- **NODE_ENV** – `development`, `staging`, or `production` -- **PORT** – Express port (default 3000 prod, 8080 dev) -- **HTTP_PORT** – Alternative port alias -- **HOSTING_DOMAIN** – Public domain for overlay advertisement (e.g., `http://localhost:8080`) -- **SERVER_PRIVATE_KEY** – 256-bit hex private key for server identity and auth signing (required) -- **ROUTING_PREFIX** – Optional path prefix for all routes (e.g., `/api`) -- **ENABLE_WEBSOCKETS** – Set to `'true'` to enable real-time messaging (default true) -- **LOGGING_ENABLED** – Set to `'true'` for verbose debug logging -- **WALLET_STORAGE_URL** – URL of wallet storage service (e.g., `https://storage.babbage.systems`) -- **KNEX_DB_CLIENT** – Database client (default: `mysql`) -- **KNEX_DB_CONNECTION** – JSON connection config: `{"host":"localhost","port":3306,"user":"root","password":"...","database":"messagebox-backend"}` -- **MIGRATE_KEY** – Optional key to authorize migration operations -- **ENABLE_FIREBASE** – Set to `'true'` to enable Firebase push notifications -- **FIREBASE_PROJECT_ID** – GCP project ID for Firebase -- **FIREBASE_SERVICE_ACCOUNT_JSON** – Firebase service account JSON (inline) -- **FIREBASE_SERVICE_ACCOUNT_PATH** – Path to Firebase service account JSON file - -## Dependencies -- **Database** – MySQL 8.0 via knex + mysql2 -- **@bsv packages** - - `@bsv/sdk` – Cryptography, key handling - - `@bsv/auth-express-middleware` – BRC-103 request/response authentication - - `@bsv/authsocket` – Authenticated WebSocket server - - `@bsv/payment-express-middleware` – Optional payment verification - - `@bsv/wallet-toolbox` – Wallet operations -- **External services** - - Wallet Storage (via `WALLET_STORAGE_URL`) – Stores key derivation metadata - - Firebase Admin SDK (optional) – Push notifications to registered devices -- **Key packages** – Express, body-parser, dotenv, socket.io (WebSocket), web-push, firebase-admin, swagger-jsdoc - -## Operational concerns -- **Local dev** – `npm run dev` with hot-reload via nodemon, requires MySQL running (use docker-compose) -- **Production** – `npm run build && npm start` compiles TypeScript to `out/`, then `node out/src/index.js` starts -- **Migrations** – Auto-run after server starts (5s delay in code); use `MIGRATE_KEY` to authorize if needed -- **Health endpoint** – No explicit health check route; monitor WebSocket and HTTP endpoints -- **Scaling** – Single instance by design; WebSocket rooms are in-memory, horizontal scaling requires sticky sessions or external message broker -- **Database** – MySQL 8.0 required; note query performance with large message volumes (index on identity keys + messageBox) -- **WebSocket** – `@bsv/authsocket` handles multiplexing; each client connects once but can join multiple rooms - -## Spec conformance -- **BRC-103** – Mutual authentication on all requests and WebSocket handshakes -- **BRC-2** – Optional AES-encrypted message payloads (client-side encryption supported) -- **SHIP** – Overlay advertisement via `@bsv/sdk` PublicKey operations -- **MessageBox protocol** – Custom identity + messageBox type routing model - -## Integration points -- **@bsv/messagebox-client** – Client library that connects to this server, handles auth, encryption, WebSocket -- **Wallet Storage** – Derives keys from SERVER_PRIVATE_KEY via configured wallet storage endpoint -- **Overlay nodes** – Can advertise MessageBox capabilities via SHIP protocol using HOSTING_DOMAIN -- **Payment processing** – Optionally enforces BRC-100 payment verification on message send (via `@bsv/payment-express-middleware`) +# Message Box Server Maintainer Guide + +## Scope + +`@bsv/messagebox-server` is the private deployable service behind +`@bsv/message-box-client`. It stores encrypted messages, enforces +recipient-controlled delivery permissions and fees, authenticates HTTP and +WebSocket peers with BRC-103, and optionally delivers Firebase notifications. +It is shipped as a container, not a public npm artifact. + +## Service contract + +Public pre-auth routes: + +- `GET /health` — process liveness +- `GET /ready` — non-sensitive database readiness +- `GET /docs` and `GET /openapi.json` — runtime documentation + +Authenticated routes: + +- `POST /sendMessage` — one to 100 recipients +- `POST /listMessages` — deterministic pages of at most 1,000 records +- `POST /acknowledgeMessage` — at most 1,000 IDs +- `POST /registerDevice` — an FCM token cannot be reassigned across identities +- `GET /devices` — redacted, bounded pagination +- `POST /permissions/set` +- `GET /permissions/get` +- `GET /permissions/list` — bounded pagination +- `GET /permissions/quote` — one to 100 recipients with bounded concurrency + +WebSocket rooms are `{identityKey}-{messageBox}`. Identity comes from the +authenticated transport, never solely from a payload claim. WebSocket sends +reuse the complete HTTP validation, permission, fee, payment, duplicate, and +persistence path. + +## Security and availability invariants + +- Message Box is a public protocol service. Default CORS is credential-free + wildcard access, including opaque `Origin: null`. Operators may explicitly + select an exact allowlist or disable cross-origin browser access. +- Do not use CORS or CSP as authentication. Preserve BRC-103 authentication, + recipient ownership, end-to-end encryption, permission/payment policy, + bounded work, and rate limits. +- Permission/fee storage failures fail closed. They must never become free or + allowed delivery. +- Implicit default permission reads do not write rows. Explicit box-wide and + sender-specific permissions are uniquely keyed by normalized sender scope. +- Log no service private keys, complete FCM tokens, auth material, payment + payloads, or plaintext message bodies. +- Database migrations, wallet/auth initialization, and WebSocket setup finish + before the process listens. A failed prerequisite is a failed process. +- `PORT` takes precedence over compatibility fallback `HTTP_PORT`; the default + is 8080. The container serves Node HTTP/WebSocket traffic directly. A trusted + platform ingress may sit in front of it. +- Horizontal WebSocket routing and the default rate-limit store are + process-local. Multiple replicas require sticky routing or an authenticated + shared broker/store. + +## Shared policy files + +`src/security/edgePolicy.ts` and `src/security/rateLimitPolicy.ts` are +byte-for-byte synchronized from the canonical WAB implementations. Do not make +message-box-only edits or reformat them. Change the canonical policy and run: + +```bash +pnpm sync:service-edge-policy +pnpm sync:service-rate-limit-policy +``` + +They are intentionally excluded from this service's local Prettier pass. ## File map -- **src/** - - `index.ts` – Entry point: loads env, creates HTTP + WebSocket servers, starts migrations - - `app.ts` – Express app setup, route mounting, auth middleware - - `routes/` – HTTP endpoints: `sendMessage.ts`, `listMessages.ts`, `acknowledgeMessage.ts`, device listing, permissions - - `config/firebase.ts` – Firebase Admin SDK initialization - - `utils/` – Helpers: logger, notification sending, message permissions - - `migrations/` – Knex schema migrations for messages, permissions, devices - - `types/` – TypeScript interfaces for notifications, permissions -- **knexfile.ts** – Knex configuration, driver selection, connection pooling -- **Dockerfile** – Multi-stage Node + nginx container -- **docker-compose.yml** – Services: backend, MySQL, PHPMyAdmin -- **nginx.conf** – Reverse proxy config -- **.env.example** – Template with all required/optional variables + +- `src/index.ts` — standalone migration/init/listen lifecycle +- `src/app.ts` — Express, auth, public edge policy, and route wiring +- `src/compose.ts`, `src/context.ts`, `src/runtimeDeps.ts` — embeddable API and + dependency binding +- `src/routes/` — HTTP handlers +- `src/security/` — shared edge/rate policy and Message Box WebSocket policy +- `src/migrations/` — Knex schema history +- `src/config/firebase.ts` — optional Firebase initialization +- `src/telemetry.ts`, `src/utils/logger.ts` — observability +- `Dockerfile` — digest-pinned Node 24 build/runtime +- `README.md`, `DEPLOYING.md` — developer and operator documentation +- `specs/messaging/message-box-http.yaml` — reviewed source contract + +## Required checks + +From `infra/message-box-server`: + +```bash +npm ci --ignore-scripts +npm rebuild better-sqlite3 +npm run typecheck +npm run format:check +npm run lint +npm test +npm run test:coverage +npm run build +npm audit --audit-level=high +``` + +From the repository root, also run synchronized-policy, OpenAPI/codegen, +documentation, repository-health, and Linux container CI gates. Do not rely on +a macOS image build as Linux/amd64 release evidence. + +## Deployment + +Use the repository's git-triggered release/deployment path and immutable image +evidence. Keep CORS mode, trusted proxy hops, database and wallet endpoints, +Firebase credentials, OTLP settings, probes, rollback, and migration evidence +under operator control. Never commit secrets. Do not bump or publish an npm +version as part of service maintenance. diff --git a/infra/message-box-server/BASELINE.md b/infra/message-box-server/BASELINE.md index 3c15ab2bb..d7a395758 100644 --- a/infra/message-box-server/BASELINE.md +++ b/infra/message-box-server/BASELINE.md @@ -3,50 +3,57 @@ > Captured: 2026-04-24. Reflects state at time of ts-stack migration. ## Identity -| Field | Value | -|-------|-------| -| Package | `@bsv/messagebox-server` | -| Path | `packages/messaging/message-box-server` | -| npm | private — not published | -| Version | 1.1.5 | -| Criticality | **Tier 2** — private messaging service; failure isolates to message delivery domain | -| Reliability Level | **RL1** — builds, 3 test files, coverage tooling present but coverage unknown | -| Owner | @sirdeggen | -| Backup owner | — | + +| Field | Value | +| ----------------- | ----------------------------------------------------------------------------------- | +| Package | `@bsv/messagebox-server` | +| Path | `packages/messaging/message-box-server` | +| npm | private — not published | +| Version | 1.1.5 | +| Criticality | **Tier 2** — private messaging service; failure isolates to message delivery domain | +| Reliability Level | **RL1** — builds, 3 test files, coverage tooling present but coverage unknown | +| Owner | @sirdeggen | +| Backup owner | — | ## Build -| Field | Value | -|-------|-------| -| Build command | `tsc` | -| Build status | ✅ Passing (assumed — not yet verified in ts-stack CI) | -| Outputs | Compiled JS (ESM via tsc) | + +| Field | Value | +| ------------- | ------------------------------------------------------ | +| Build command | `tsc` | +| Build status | ✅ Passing (assumed — not yet verified in ts-stack CI) | +| Outputs | Compiled JS (ESM via tsc) | ## Tests -| Field | Value | -|-------|-------| -| Test command | `node --experimental-vm-modules node_modules/jest/bin/jest.js --config=jest.config.mjs` | -| Test files | 3 | + +| Field | Value | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| Test command | `node --experimental-vm-modules node_modules/jest/bin/jest.js --config=jest.config.mjs` | +| Test files | 3 | | Coverage command | `node --experimental-vm-modules node_modules/jest/bin/jest.js --config=jest.config.mjs --coverage --coverageReporters=text --coverageReporters=html` | -| Coverage | Not yet captured as baseline | -| Known flaky | None identified | +| Coverage | Not yet captured as baseline | +| Known flaky | None identified | ## Lint -| Field | Value | -|-------|-------| -| Linter | ts-standard | + +| Field | Value | +| ------------ | --------------------- | +| Linter | ts-standard | | Lint command | `ts-standard --fix .` | ## Dependencies -| Type | Count | Packages | -|------|-------|---------| -| Production | 16 | @bsv/auth-express-middleware, @bsv/authsocket, @bsv/payment-express-middleware, @bsv/sdk, @bsv/wallet-toolbox, body-parser, dotenv, express, firebase-admin, knex, mongodb, mysql2, prettyjson, swagger-jsdoc, swagger-ui-express, web-push | + +| Type | Count | Packages | +| ---------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Production | 16 | @bsv/auth-express-middleware, @bsv/authsocket, @bsv/payment-express-middleware, @bsv/sdk, @bsv/wallet-toolbox, body-parser, dotenv, express, firebase-admin, knex, mongodb, mysql2, prettyjson, swagger-jsdoc, swagger-ui-express, web-push | ## Known Issues & Incidents + - Private package — not published to npm. - Service, not a library; requires database and external dependencies at runtime. - Uses `--experimental-vm-modules` flag for Jest ESM support. ## Migration Gate Checklist (MBGA §13.3) + - [x] BASELINE.md captured - [ ] Conformance runner vectors passing - [ ] Contract tests green diff --git a/infra/message-box-server/DEPLOYING.md b/infra/message-box-server/DEPLOYING.md index 11ef8ecd9..e94230ae0 100644 --- a/infra/message-box-server/DEPLOYING.md +++ b/infra/message-box-server/DEPLOYING.md @@ -1,120 +1,150 @@ -# MessageBox Server Deployment Guide -This guide covers how to deploy the MessageBox Server — a secure peer-to-peer message relay supporting authenticated HTTP and WebSocket communication and encrypted payloads. -________________________________________ -### Overview -The MessageBox Server is a node-based Express server that enables identity-authenticated messaging over HTTP and WebSocket. Key features: -- Authenticated message sending and receiving -- Message box creation and routing by identity key -- WebSocket-based real-time delivery -- AES-encrypted message payloads (handled by clients) -________________________________________ -### Requirements -- Node.js v18 or higher -- MySQL v8 or higher -- Docker (optional for local setup) -- A valid SERVER_PRIVATE_KEY - a 256-bit hex private key -- Wallet Storage instance URL (e.g., https://wallet-storage.babbage.systems) -________________________________________ -### Project Structure +# Message Box Server deployment + +This service is released from the ts-stack repository as a Node.js 24 +container. Prefer the repository CI/CD release path so the deployed image is +traceable to reviewed source, scanned, signed, and addressed by an immutable +tag or digest. + +## Dependencies + +- MySQL 8 +- a dedicated 256-bit `SERVER_PRIVATE_KEY` +- wallet storage when required by the chosen wallet configuration +- optional Firebase credentials +- optional OTLP/HTTP collector + +Do not copy secrets into the image, source repository, build arguments, or +logs. Supply them through the deployment platform's secret mechanism and +rotate them through the owning operations process. + +## Build and validate + +CI builds Linux/amd64 images. For a local functional check: + ```bash -. -├── src/ # Server source files (Express app, routes, WebSocket, logger) -├── knexfile.js # DB connection settings -├── app.ts # Express and route setup -├── index.ts # Server entry point with WebSocket support -├── .env # Environment variable configuration -└── ... +npm ci --ignore-scripts +npm rebuild better-sqlite3 +npm run typecheck +npm run lint +npm test +npm run build +docker build -t message-box-server:local . ``` -________________________________________ -### Required Environment Variables -Create a .env file in the root with the following: +The Dockerfile uses a digest-pinned Node 24 Alpine base, performs a +deny-by-default dependency install, rebuilds only the required native module, +and runs the final service as the unprivileged `node` user. + +## Required runtime configuration ```env NODE_ENV=production -SERVER_PRIVATE_KEY=your_hex_64_char_key -WALLET_STORAGE_URL=https://wallet-storage.babbage.systems +SERVER_PRIVATE_KEY= +KNEX_DB_CLIENT=mysql2 +KNEX_DB_CONNECTION={"host":"mysql","port":3306,"user":"messagebox","password":"","database":"messagebox"} ENABLE_WEBSOCKETS=true -PORT=5001 -ROUTING_PREFIX= ``` -Optional: +Typical deployment options: ```env -LOGGING_ENABLED=true -SKIP_NGINX=true # Set to skip auto-starting nginx in production -``` -________________________________________ -### Deployment Steps -**Local Development** -1. Install dependencies: -```bash -npm install -``` -2. Start MySQL database (you can use Docker or your local MySQL): - - If using Docker: - ```bash - docker compose up --build - ``` - - Otherwise, ensure a database is running and matches your knexfile.js. -3. Set up environment variables: -```bash -cp .env.example .env -# Fill in actual values -``` -4. Run migrations: -```bash -npm run migrate -``` -5. Start the server in development mode: -```bash -npm run dev -``` -Server will run at http://localhost:5001 -________________________________________ -### Production Deployment -1. Ensure production .env is configured correctly -2. Build the project (if applicable): -```bash -npm run build -``` -3. Run the server: -```bash -npm start +PORT=8080 +ROUTING_PREFIX= +BSV_NETWORK=mainnet +WALLET_STORAGE_URL=https://storage.example.com +MESSAGE_BOX_CORS_MODE=public +TRUST_PROXY_HOPS=1 +OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.example.com ``` -4. Optionally, configure nginx to reverse proxy to the server. Nginx will start automatically unless SKIP_NGINX=true is set. -________________________________________ -### WebSocket Support -WebSocket support is enabled by default unless ENABLE_WEBSOCKETS=false is set in .env. -Clients must authenticate with their identity key to establish a WebSocket connection. -________________________________________ -### Testing -To run tests: -```bash -npm test + +Use `TRUST_PROXY_HOPS` only when the exact proxy topology is known. The service +ignores forwarded addresses by default. + +## Browser access + +Message Box is a public protocol service. Keep `MESSAGE_BOX_CORS_MODE=public` +for arbitrary deployed apps, wallet UIs, mobile webviews, native shells, and +unknown future domains. This mode is credential-free wildcard CORS and +supports opaque `Origin: null`. + +Operators with a closed deployment may choose: + +```env +MESSAGE_BOX_CORS_MODE=allowlist +MESSAGE_BOX_CORS_ALLOWED_ORIGINS=https://app.example.com,https://wallet.example.com ``` -This includes unit tests and integration tests for HTTP and WebSocket message delivery. -________________________________________ -### Docker Usage -Use Docker only if you're running a local test instance: + +or disable CORS entirely with `MESSAGE_BOX_CORS_MODE=disabled`. Never combine +wildcard origins with credentials. CSP governs served documents such as +Swagger UI; it is not API authentication. + +## Database migrations + +Migrations run automatically during server startup. They can be applied +explicitly from a built release: + ```bash -docker compose up -- build +npx --no-install knex --knexfile out/knexfile.js migrate:latest --env production ``` -- Web server runs on port 3002 -- Database runs on port 3001 -- PHPMyAdmin (for SQL browsing) runs on port 3003 -________________________________________ -### Deployment on Google Cloud Run -MessageBox can also be containerized and deployed on Google Cloud Run or any similar service. See DEPLOYING.md in backend/ for overlay-specific LARS deployment instructions. -________________________________________ -### Related Projects -- [MessageBoxClient](https://github.com/bitcoin-sv/p2p) -- [WalletClient](https://github.com/bitcoin-sv) -- [Auth Middleware](https://www.npmjs.com/package/@bsv/auth-express-middleware) -________________________________________ -📄 License -The MessageBox Server is licensed under the [Open BSV License](https://www.bsvlicense.org/). -________________________________________ +Back up the database before schema changes and retain the prior immutable image +for application rollback. A rollback must consider whether a migration is +backward-compatible with the prior application version. + +## Probes + +- `GET /health` — liveness; does not authenticate or disclose dependencies +- `GET /ready` — database readiness; returns 503 while unavailable + +Gate traffic on readiness. Add an authenticated WebSocket handshake probe when +live messaging is a required deployment capability. + +## Ingress and timeouts + +The image serves HTTP and WebSocket traffic directly on `PORT` (8080 by +default). Put the platform ingress or load balancer in front of the container +and keep its ceilings aligned with the application: + +- HTTP JSON default: 4 MiB +- WebSocket signed-event default: 1 MiB +- bounded header, request, keep-alive, socket, and upstream timeouts +- bounded pre-auth and authenticated rates +- bounded concurrent application work + +At multiple replicas, the default in-memory rate-limit store is per process. +Enforce an aggregate policy at the trusted ingress or configure a shared store. +WebSocket routing is also process-local; use sticky sessions or an +authenticated shared broker. + +## Firebase + +Firebase is off unless `ENABLE_FIREBASE=true`. Prefer workload identity or a +secret-mounted service-account file. If inline JSON is unavoidable, inject it +as a secret and ensure the platform does not expose environment values in logs +or diagnostic UIs. + +## Observability + +Set an OTLP endpoint and resource attributes for production. Logs and traces +must not include private keys, complete device tokens, auth signatures, +payment payloads, or plaintext message content. + +## Rollout + +1. Deploy the immutable image to a canary or staging environment. +2. Confirm migrations completed. +3. Confirm `/health` and `/ready`. +4. Exercise authenticated send, list, and acknowledge. +5. Exercise an authenticated WebSocket send when enabled. +6. Verify public/default or allowlisted browser access as configured. +7. Verify rate-limit, body-limit, and timeout telemetry. +8. Roll out gradually and monitor error, latency, database, and connection + metrics. + +Rollback to the prior image digest when the database remains compatible. +Record the deployed source revision, image digest, migration state, validation +evidence, and rollback result in the owning operations system. + +## License +See [LICENSE.txt](./LICENSE.txt). diff --git a/infra/message-box-server/Dockerfile b/infra/message-box-server/Dockerfile index fc5c78b5e..381ed42a6 100644 --- a/infra/message-box-server/Dockerfile +++ b/infra/message-box-server/Dockerfile @@ -30,10 +30,6 @@ RUN cp out/knexfile.js ./knexfile.js # ---- Production stage ---- FROM node@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd -# Install nginx from the repository associated with the pinned Alpine base. -RUN apk add --no-cache nginx && \ - chown -R nginx:www-data /var/lib/nginx - WORKDIR /app # Copy package files and install production deps only. @@ -50,10 +46,8 @@ RUN apk add --no-cache --virtual .build-deps g++ make python3 \ COPY --from=builder --chown=root:root --chmod=0555 /app/out ./out COPY --from=builder --chown=root:root --chmod=0444 /app/knexfile.js ./knexfile.js -# Copy nginx config -COPY ./nginx.conf /etc/nginx/nginx.conf - -# Expose the API port +# The Node process serves HTTP and WebSocket traffic directly. Operators can +# place a platform/load-balancer proxy in front of the container. EXPOSE 8080 USER node diff --git a/infra/message-box-server/README.md b/infra/message-box-server/README.md index 8d8b03368..6fa125f9e 100644 --- a/infra/message-box-server/README.md +++ b/infra/message-box-server/README.md @@ -1,402 +1,183 @@ # Message Box Server -A secure, peer-to-peer message routing server designed for the Bitcoin SV ecosystem. Supports both HTTP and WebSocket message transport, encrypted payloads, identity-based routing, and overlay advertisement via SHIP. - -## Table of Contents - -1. [Overview](#1-overview) -2. [Concepts](#2-concepts) -3. [Environment Variables](#3-environment-variables) -4. [Quick Start](#4-quick-start) -5. [Examples](#5-examples) -6. [API Reference](#6-api-reference) - - [POST /sendMessage](#post-sendmessage) - - [POST /listMessages](#post-listmessages) - - [POST /acknowledgeMessage](#post-acknowledgemessage) -7. [WebSocket Support](#7-websocket-support) -8. [Authentication](#8-authentication) -9. [Scripts](#9-scripts) -10. [Deploying](#10-deploying) -11. [License](#11-license) - -## 1. Overview - -MessageBox is a peer-to-peer messaging API that enables secure, encrypted, and authenticated communication between users on the MetaNet. This is primarily achieved through a message-box architecture combined with optional overlay routing and real-time WebSocket transport. - -When a user sends a message, they must specify the messageBox type and the intended recipient (an identity key). This design allows for protocol-specific messageBox types to be defined at higher application layers, while maintaining clear separation of messages by type and recipient. Each message is routed into a box associated with a specific recipient and use case. - -Security is a critical aspect of MessageBox. It relies on [AuthExpress middleware](https://github.com/bitcoin-sv/auth-express-middleware) to ensure that only the recipient can access and acknowledge their own messages. Encrypted payloads are supported automatically following [BRC-2](https://github.com/bitcoin-sv/BRCs/blob/master/wallet/0002.md), using secure symmetric encryption between authenticated peers. - -MessageBox also supports [@bsv/authsocket](https://www.npmjs.com/package/@bsv/authsocket) for real-time authenticated WebSocket communication. This enables clients to receive messages instantly and interact with rooms associated with their identity key and chosen messageBox. - -To interact with MessageBox, use [MessageBoxClient](https://github.com/bitcoin-sv/p2p), the client-side library designed to handle authentication, encryption, WebSocket communication, and overlay resolution. - -## 2. Concepts -- **Identity Key:** All messages are addressed to an identity key (a public key) -- **MessageBox:** A named message stream associated with an identity key (e.g., payment_inbox) -- **Encrypted Payloads:** Messages can be AES-encrypted and include metadata -- **Live Messaging:** Clients can join WebSocket rooms and receive messages in real-time -- **Acknowledgment:** Messages must be acknowledged to be removed from the database -________________________________________ - -## 3. Environment Variables -**Variables** - -NODE_ENV - Set to development, staging, or production - -PORT - Port for the HTTP server (default: 8080 or 3000) - -ROUTING_PREFIX - (Optional) Prefix for all HTTP and WebSocket routes (e.g., /api) - -HOSTING_DOMAIN - (Optional) Full domain where this server is hosted (used in overlay metadata) - -SERVER_PRIVATE_KEY - Required. 256-bit hex private key used for identity, signing, and authentication - -WALLET_STORAGE_URL - URL of a wallet-storage service that stores key derivation metadata - -NETWORK - Target network chain (e.g., main, test, or regtest) - -KNEX_DB_CONNECTION - (Optional) JSON-formatted connection string for MySQL/Knex (if not using default) - -ENABLE_WEBSOCKETS - Set to 'true' to enable real-time messaging over WebSocket - -LOGGING_ENABLED - Set to 'true' to enable verbose debug logging in any environment - -TRUST_PROXY_HOPS - (Optional) Exact trusted reverse-proxy hop count from 0 through 10 - -MESSAGE_BOX_PRE_AUTH_RATE_LIMIT_MAX / MESSAGE_BOX_PRE_AUTH_RATE_LIMIT_WINDOW_MS - -Per-IP protection before authentication (defaults to 300 requests per minute) - -MESSAGE_BOX_AUTHENTICATED_RATE_LIMIT_MAX / -MESSAGE_BOX_AUTHENTICATED_RATE_LIMIT_WINDOW_MS - Per-identity protection before -payment and route work (defaults to 1,000 requests per minute) - -MESSAGE_BOX_CORS_MODE - `public` (default), `allowlist`, or `disabled` - -MESSAGE_BOX_CORS_ALLOWED_ORIGINS - Exact comma-separated HTTP(S) origins in -allowlist mode - -MESSAGE_BOX_MAX_BODY_BYTES - JSON body ceiling (default 4 MiB) - -MESSAGE_BOX_WEBSOCKET_MAX_BODY_BYTES - Signed WebSocket event ceiling (default -1 MiB) - -________________________________________ - -Invalid or unbounded rate-limit/proxy values fail startup. Forwarding headers -remain ignored unless `TRUST_PROXY_HOPS` is explicitly configured. The default -in-memory store is per process; replicated deployments must also enforce an -aggregate policy at their trusted ingress until a shared store is configured. -Browser access is public by default without cookie credentials so deployed -apps and wallet UIs on unknown domains can connect. Origin restriction does -not replace BRC-103 identity, recipient permission, payment, or box ownership. - -WebSocket identity comes only from the signed BRC-103 transport. A payload -claim must match it, room access is limited to the authenticated identity, and -delivery notifications target only connections authenticated as the -recipient. WebSocket sends reuse the HTTP route's validation, fee, duplicate, -and persistence policy. - -## 4. Quick Start - -To run the MessageBox Server locally or in a hosted environment, follow the steps below. - -1. **Clone the Repository** -```bash -git clone https://github.com/bitcoin-sv/messagebox-server.git -cd messagebox-server -``` - -2. **Configure Environment Variables** -Create a .env file based on the variables listed in the Environment Variables section: +Private deployable service for authenticated, optionally encrypted +store-and-forward messaging. It provides HTTP polling, live authenticated +WebSockets, recipient permissions and fees, wallet-payment handling, and +optional Firebase push notifications. + +The maintained source lives in +[`bsv-blockchain/ts-stack`](https://github.com/bsv-blockchain/ts-stack/tree/main/infra/message-box-server). +The service is distributed as a container; it is not a public npm package. + +## Runtime model + +- Node.js 24 +- Express 5 +- MySQL 8 through Knex +- BRC-103 authentication over HTTP and WebSocket transports +- `@bsv/wallet-toolbox` for the service wallet +- optional Firebase Admin push delivery +- optional OpenTelemetry OTLP export + +Clients normally use +[`@bsv/message-box-client`](../../packages/messaging/message-box-client). +Message bodies are encrypted by the client unless plaintext is explicitly +requested. The server authenticates identities and routes the stored payload; +it does not hold recipient decryption keys. + +## Routes + +Public routes: + +| Method | Path | Purpose | +| ------ | --------------- | ------------------------ | +| GET | `/health` | Process liveness | +| GET | `/ready` | Database readiness | +| GET | `/docs` | Swagger UI | +| GET | `/openapi.json` | Runtime OpenAPI document | + +BRC-103-authenticated routes: + +| Method | Path | Purpose | +| ------ | --------------------- | -------------------------------------------- | +| POST | `/sendMessage` | Send to one or up to 100 recipients | +| POST | `/listMessages` | Read an identity-owned message box | +| POST | `/acknowledgeMessage` | Delete acknowledged identity-owned messages | +| POST | `/registerDevice` | Register a Firebase push token | +| GET | `/devices` | List registered devices with redacted tokens | +| POST | `/permissions/set` | Set a sender-specific or box-wide permission | +| GET | `/permissions/get` | Get a permission | +| GET | `/permissions/list` | List permissions | +| GET | `/permissions/quote` | Quote one or up to 100 recipients | + +`ROUTING_PREFIX` prefixes HTTP routes in deployments that mount the service +below a path. + +## Public-service edge policy + +Message Box is intended for deployed applications, wallet UIs, mobile +webviews, native shells, and callers on unknown domains. + +`MESSAGE_BOX_CORS_MODE` controls browser access: + +- `public` — default credential-free wildcard access, including `Origin: null` +- `allowlist` — exact origins from `MESSAGE_BOX_CORS_ALLOWED_ORIGINS` +- `disabled` — emit no CORS policy + +Wildcard origin is never combined with credentials. CORS is not +authentication, and CSP is not API authorization. BRC-103 identity, box +ownership, permissions, payments, rate limits, body limits, and end-to-end +encryption remain the security boundaries. + +HTTP and WebSocket payloads, concurrency, request rates, timeouts, proxy trust, +and security headers are bounded and environment-configurable. Invalid or +unbounded numeric settings fail startup. + +## Required configuration + +| Variable | Purpose | +| -------------------- | ----------------------------------- | +| `SERVER_PRIVATE_KEY` | Dedicated 256-bit service root key | +| `KNEX_DB_CONNECTION` | JSON MySQL connection configuration | + +Important optional configuration: + +| Variable | Default / purpose | +| ------------------------------------------ | --------------------------------------- | +| `NODE_ENV` | `development` | +| `PORT` / `HTTP_PORT` | `8080`; `PORT` takes precedence | +| `ROUTING_PREFIX` | Empty | +| `ENABLE_WEBSOCKETS` | `true` | +| `WALLET_STORAGE_URL` | Wallet storage service URL | +| `BSV_NETWORK` | `mainnet`; use `testnet` for test chain | +| `ENABLE_FIREBASE` | Firebase disabled unless `true` | +| `LOGGING_ENABLED` | Verbose logs when `true` | +| `TRUST_PROXY_HOPS` | Exact trusted proxy hops, 0–10 | +| `MESSAGE_BOX_CORS_MODE` | `public` | +| `MESSAGE_BOX_CORS_ALLOWED_ORIGINS` | Exact origins for allowlist mode | +| `MESSAGE_BOX_MAX_BODY_BYTES` | 4 MiB | +| `MESSAGE_BOX_WEBSOCKET_MAX_BODY_BYTES` | 1 MiB | +| `MESSAGE_BOX_PRE_AUTH_RATE_LIMIT_MAX` | 300 per minute per IP | +| `MESSAGE_BOX_AUTHENTICATED_RATE_LIMIT_MAX` | 1,000 per minute per identity | + +See +[`docs/infrastructure/service-edge-security.md`](../../docs/infrastructure/service-edge-security.md) +for the complete shared edge policy. + +## Local development + +From this directory: ```bash cp .env.example .env -``` - -Then fill in or update the necessary fields in .env, including: - -SERVER_PRIVATE_KEY – your root private key for identity/auth - -WALLET_STORAGE_URL – points to a wallet-storage instance - -(Optional) KNEX_DB_CONNECTION – if you're not using the default MySQL config - -For local development, use: - -```bash -.env -NODE_ENV=development -BSV_NETWORK=local -``` - -3. **Install Dependencies** -```bash -npm install -``` - -4. **Start the Server** - -```bash +npm ci --ignore-scripts +npm rebuild better-sqlite3 +docker compose up -d backend-mysql npm run dev ``` -**Note:** -MessageBox Server requires a MySQL database for storing messages. -See [Deploying](#10-deploying) for instructions on setting up the required database locally or in production environments. - -This launches the Express-based MessageBox Server on the configured port (default: 8080 or 3000 in production). WebSocket support will be enabled if ENABLE_WEBSOCKETS=true in your environment. - -You can customize the HTTP port using the PORT variable in .env. - -You're now ready to send and receive messages using the MessageBoxClient, test endpoints, and participate in the overlay network. - -________________________________________ - -## 5. Examples - -This section provides quick examples for sending, listing, and acknowledging messages using the MessageBoxClient library. - -1. **Sending a Message** -```ts -import { WalletClient } from '@bsv/sdk' -import { MessageBoxClient } from '@bsv/p2p' - -const wallet = new WalletClient() -const mb = new MessageBoxClient({ - walletClient: wallet, - host: 'http://localhost:8080' // your MessageBoxServer instance -}) - -await mb.sendMessage({ - recipient: '028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1', - messageBox: 'demo_inbox', - body: { text: 'Hello there!' } -}) -``` - -2. **Listening for Live Messages (WebSocket)** -```ts -await mb.initializeConnection() - -await mb.listenForLiveMessages({ - messageBox: 'demo_inbox', - onMessage: (msg) => { - console.log('New Message:', msg) - } -}) -``` - -3. **Listing Messages via HTTP** -```ts -const messages = await mb.listMessages({ - messageBox: 'demo_inbox' -}) - -console.log('All Messages:', messages) -``` - -4. **Acknowledging Messages (Marking as Read)** -```ts -const toAcknowledge = messages.map(m => m.messageId.toString()) - -await mb.acknowledgeMessage({ - messageIds: toAcknowledge -}) -``` - -5. **Sending a Live Message with Fallback** -```ts -await mb.sendLiveMessage({ - recipient: '028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1', - messageBox: 'demo_inbox', - body: 'This will try WebSocket first, and fallback to HTTP if needed.' -}) -``` - -________________________________________ - -## 6. API Reference -The MessageBox Server exposes a small set of authenticated HTTP endpoints to support secure, store-and-forward messaging. All routes require identity-based authentication using @bsv/auth-express-middleware. - -### POST /sendMessage -Send a message to a specific recipient’s message box. - -**Request Body** -```json -{ - "message": { - "recipient": "IDENTITY_PUBLIC_KEY", - "messageBox": "payment_inbox", - "messageId": "abc123", - "body": "{\"amount\":10000}" - } -} -``` - -**Response** -```json -{ - "status": "success", - "messageId": "abc123", - "message": "Your message has been sent to IDENTITY_PUBLIC_KEY" -} -``` - -Messages are persisted in the recipient’s messageBox. -messageId must be globally unique (can be derived using HMAC). +Use a dedicated local-only `SERVER_PRIVATE_KEY`; never reuse a production key. +The Compose stack expects that value from the environment. -Duplicate messageIds will be ignored. +Run checks: -### POST /listMessages -List all messages from a given messageBox owned by the authenticated identity. - -**Request Body** -```json -{ - "messageBox": "payment_inbox" -} -``` - -**Response** -```json -{ - "status": "success", - "messages": [ - { - "messageId": "abc123", - "body": "{\"amount\":10000}", - "sender": "SENDER_PUBLIC_KEY", - "created_at": "2024-12-01T12:00:00Z", - "updated_at": "2024-12-01T12:01:00Z" - } - ] -} +```bash +npm run typecheck +npm run lint +npm test +npm run test:coverage +npm run build +npm audit --audit-level=high ``` -Message bodies will be returned as strings (plain or encrypted). +The separate client integration suite requires configured wallet, server, +database, WebSocket, and overlay services and is intentionally opt-in. -Empty arrays are returned if no messages exist or the box is unregistered. +## Database and readiness -### POST /acknowledgeMessage -Permanently delete one or more messages after they have been processed. +Migrations in `src/migrations/` run at startup. `/health` reports only process +liveness. `/ready` verifies database connectivity and returns 503 without +exposing dependency details while the service is unavailable. -**Request Body** -```json -{ - "messageIds": ["abc123", "def456"] -} -``` +After a production build, migrations can also be run explicitly: -**Response** -```json -{ - "status": "success" -} +```bash +npx --no-install knex --knexfile out/knexfile.js migrate:latest --env production ``` -This action cannot be undone. +## WebSockets -Only messages owned by the authenticated identity can be acknowledged. +The service uses `@bsv/authsocket`. The peer identity comes from the signed +BRC-103 transport, never solely from a payload claim. A claim must match that +identity; connections may join only identity-owned rooms; delivery +notifications target only connections authenticated as the recipient. +WebSocket sends reuse the HTTP handler's validation, fee, permission, +deduplication, and persistence behavior. -Note: All requests must include automatically signed and verified headers following [BRC-103](https://github.com/bitcoin-sv/BRCs/blob/master/peer-to-peer/0103.md) authentication. -Mutual authentication is handled transparently by [@bsv/auth-express-middleware](https://www.npmjs.com/package/@bsv/auth-express-middleware). +The in-memory connection map is process-local. Horizontally scaled deployments +need sticky routing or an authenticated shared broker. -________________________________________ +## Container -## 7. WebSocket Support -The MessageBox Server supports real-time messaging over WebSocket using @bsv/authsocket. This allows clients to send and receive messages instantly without polling. - -**Connection** -Clients should connect to the same host and port used by the HTTP server (e.g., ws://localhost:8080) and must authenticate using their identity key. - -**Authentication** -There are two ways to authenticate: - -During initial connection: Send the identityKey as part of the connection handshake. - -After connecting: Emit an authenticated event with { identityKey }. - -Once authenticated, the server emits authenticationSuccess. If the key is missing or invalid, authenticationFailed is returned. - -**Room Format** -Each messageBox uses a room in the format: +The multi-stage Dockerfile uses a digest-pinned Node 24 Alpine base, installs +dependencies without unrestricted lifecycle scripts, explicitly rebuilds the +allowlisted native module, and runs the HTTP/WebSocket service directly as the +unprivileged `node` user. ```bash -{identityKey}-{messageBox} +docker build -t message-box-server:local . ``` -For example: -028d37b94120...-payment_inbox - -**Events** - -authenticated - Authenticate using an identity key -joinRoom - Subscribe to a specific messageBox room -sendMessage - Send a message to a room (triggers DB storage + broadcast) -sendMessageAck-ROOM - Acknowledgment that the message was received by the server -sendMessage-ROOM - Broadcasted message to all listeners in the room -leaveRoom - Unsubscribe from a room - -**Example WebSocket Usage (Client)** -```ts -import { io } from 'socket.io-client' - -const socket = io('ws://localhost:8080') - -// Step 1: Authenticate after connection -socket.emit('authenticated', { identityKey }) - -// Step 2: Join a messageBox room -socket.emit('joinRoom', '028d...-payment_inbox') - -// Step 3: Send a message -socket.emit('sendMessage', { - roomId: '028d...-payment_inbox', - message: { - messageId: 'abc123', - recipient: '028d...', - body: JSON.stringify({ hello: 'world' }) - } -}) - -// Step 4: Listen for acknowledgment and broadcast -socket.on('sendMessageAck-028d...-payment_inbox', (ack) => { - console.log('Message acknowledged:', ack) -}) - -socket.on('sendMessage-028d...-payment_inbox', (msg) => { - console.log('New message received:', msg) -}) -``` - -**Notes** -Messages sent via WebSocket are also persisted in the database. - -If a room is not joined or the recipient isn't online, delivery is deferred until the recipient polls via HTTP. -If ENABLE_WEBSOCKETS is not set to 'true', this functionality is disabled. +For production deployment, use the repository release workflow and immutable +image evidence rather than a local `latest` tag. See [DEPLOYING.md](./DEPLOYING.md). -________________________________________ -## 8. Authentication -All HTTP and WebSocket communications use full mutual authentication based on [BRC-103](https://github.com/bitcoin-sv/BRCs/blob/master/peer-to-peer/0103.md). -- HTTP requests are authenticated using [@bsv/auth-express-middleware](https://www.npmjs.com/package/@bsv/auth-express-middleware), with automatically signed and verified headers. -- WebSocket connections are authenticated using [@bsv/authsocket](https://www.npmjs.com/package/@bsv/authsocket), signing the WebSocket handshake and room interactions. +## API specification -Clients authenticate automatically when using the [MessageBoxClient](https://github.com/bitcoin-sv/p2p) library. Manual integrations must follow BRC-103 signing conventions. +The reviewed source artifact is +[`specs/messaging/message-box-http.yaml`](../../specs/messaging/message-box-http.yaml). +Runtime Swagger is useful for inspection; the repository OpenAPI artifact and +implementation tests are the contract evidence. -________________________________________ - -## 9. Scripts -```bash -npm run dev # Start with hot reloading -npm run start # Start in production -npm run test # Run all tests -npm run build # Compile documentation -``` -________________________________________ +## License -## 10. Deploying -See [DEPLOYING.md](./DEPLOYING.md) for tips. -________________________________________ -## 11. License -This project is released under the [Open BSV License](https://www.bsvlicense.org/). +See [LICENSE.txt](./LICENSE.txt). diff --git a/infra/message-box-server/jest.config.integration.ts b/infra/message-box-server/jest.config.integration.ts deleted file mode 100644 index cffef89ca..000000000 --- a/infra/message-box-server/jest.config.integration.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** @type {import('ts-jest').JestConfigWithTsJest} */ -import type { JestConfigWithTsJest } from 'ts-jest' - -const config: JestConfigWithTsJest = { - preset: 'ts-jest', - testEnvironment: 'node', - extensionsToTreatAsEsm: ['.ts'], - testPathIgnorePatterns: ['dist/'], - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - transform: { - '^.+\\.ts?$': ['ts-jest', { - useESM: true, - tsconfig: { - module: 'ESNext', - moduleResolution: 'bundler', - isolatedModules: true - } - }] - }, - testMatch: ['**/test/integration/**/*.test.ts'], // Only run integration tests - verbose: true, - setupFilesAfterEnv: ['./jest.setup.ts'], // Ensures the test server/db start correctly - testTimeout: 30000 // Longer timeout for integration tests -} - -export default config diff --git a/infra/message-box-server/jest.config.mjs b/infra/message-box-server/jest.config.mjs index 21bd2a14b..827a8814e 100644 --- a/infra/message-box-server/jest.config.mjs +++ b/infra/message-box-server/jest.config.mjs @@ -1,5 +1,6 @@ // jest.config.mjs - ES Module version of Jest config export default { + roots: ['./src'], // Setup file to provide globals and env vars setupFilesAfterEnv: ['./jest.setup.mjs'], // Use Node environment @@ -12,28 +13,27 @@ export default { useESM: true, tsconfig: { module: 'ESNext', - moduleResolution: 'bundler', - }, - }, - ], + moduleResolution: 'bundler' + } + } + ] }, - + // Tell Jest these extensions should be treated as ESM extensionsToTreatAsEsm: ['.ts', '.tsx'], - + // Handle .js extensions in import statements for TypeScript files moduleNameMapper: { - '^(\\.{1,2}/.*)\\.js$': '$1', + '^(\\.{1,2}/.*)\\.js$': '$1' }, - + // Ignore compiled output testPathIgnorePatterns: ['/node_modules/', '/out/'], - + modulePathIgnorePatterns: ['/out/'], + // Important for ES modules - transformIgnorePatterns: [ - '/node_modules/(?!.*\\.mjs$)' - ], - + transformIgnorePatterns: ['/node_modules/(?!.*\\.mjs$)'], + // Use .mjs extension for Jest config to indicate ESM - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node', 'mjs'], -}; + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node', 'mjs'] +} diff --git a/infra/message-box-server/jest.config.ts b/infra/message-box-server/jest.config.ts deleted file mode 100644 index 9b3aba986..000000000 --- a/infra/message-box-server/jest.config.ts +++ /dev/null @@ -1,35 +0,0 @@ -// jest.config.ts -import type { JestConfigWithTsJest } from 'ts-jest' - -const config: JestConfigWithTsJest = { - // Use the preset specifically designed for ESM - preset: 'ts-jest/presets/default-esm', - - // Use the Node environment for testing - testEnvironment: 'node', - - // Ignore compiled output - testPathIgnorePatterns: ['dist/'], - - // These globals configure ts-jest to output ESM - globals: { - 'ts-jest': { - useESM: true, - tsconfig: { - module: 'ESNext', - moduleResolution: 'bundler' - } - } - }, - - // Tell Jest that files ending in .ts should be treated as ESM modules - extensionsToTreatAsEsm: ['.ts'], - - // Optionally, if you have imports with a .js extension in your source (or tests) - // but your source files are actually TypeScript, this mapper will remove the extension. - moduleNameMapper: { - '^(\\.{1,2}/.*)\\.js$': '$1' - } -} - -export default config diff --git a/infra/message-box-server/jest.setup.mjs b/infra/message-box-server/jest.setup.mjs index fea3efb01..1a71eb05e 100644 --- a/infra/message-box-server/jest.setup.mjs +++ b/infra/message-box-server/jest.setup.mjs @@ -1,18 +1,18 @@ // Import Jest types from the jest package -import { jest } from '@jest/globals'; +import { jest } from '@jest/globals' // Make Jest available globally -global.jest = jest; -global.expect = expect; -global.test = test; -global.describe = describe; -global.beforeEach = beforeEach; -global.afterEach = afterEach; -global.beforeAll = beforeAll; -global.afterAll = afterAll; +global.jest = jest +global.expect = expect +global.test = test +global.describe = describe +global.beforeEach = beforeEach +global.afterEach = afterEach +global.beforeAll = beforeAll +global.afterAll = afterAll // Add mock environment variables needed for tests -process.env.SERVER_PRIVATE_KEY = 'test_private_key_for_jest_environment'; -process.env.NODE_ENV = 'test'; -process.env.KNEX_DB_CLIENT = 'better-sqlite3'; -process.env.KNEX_DB_CONNECTION = '{"filename":":memory:"}'; +process.env.SERVER_PRIVATE_KEY = 'test_private_key_for_jest_environment' +process.env.NODE_ENV = 'test' +process.env.KNEX_DB_CLIENT = 'better-sqlite3' +process.env.KNEX_DB_CONNECTION = '{"filename":":memory:"}' diff --git a/infra/message-box-server/knexfile.ts b/infra/message-box-server/knexfile.ts index aaa45628c..12af123b2 100644 --- a/infra/message-box-server/knexfile.ts +++ b/infra/message-box-server/knexfile.ts @@ -2,9 +2,10 @@ import dotenv from 'dotenv' import type { Knex } from 'knex' dotenv.config() -const connectionConfig = process.env.KNEX_DB_CONNECTION != null && process.env.KNEX_DB_CONNECTION.trim() !== '' - ? JSON.parse(process.env.KNEX_DB_CONNECTION) - : undefined +const connectionConfig = + process.env.KNEX_DB_CONNECTION != null && process.env.KNEX_DB_CONNECTION.trim() !== '' + ? JSON.parse(process.env.KNEX_DB_CONNECTION) + : undefined const config: Knex.Config = { client: process.env.KNEX_DB_CLIENT ?? 'mysql2', diff --git a/infra/message-box-server/nginx.conf b/infra/message-box-server/nginx.conf deleted file mode 100644 index 54863f9e2..000000000 --- a/infra/message-box-server/nginx.conf +++ /dev/null @@ -1,42 +0,0 @@ -user nginx; -worker_processes auto; -daemon off; -error_log stderr info; -pid /var/run/nginx.pid; -events { - worker_connections 1024; -} -http { - access_log stdout; - include /etc/nginx/mime.types; - default_type application/octet-stream; - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - sendfile on; - keepalive_timeout 15s; - client_header_timeout 15s; - client_body_timeout 60s; - send_timeout 60s; - gzip on; - server { - listen 8080 http2; - listen [::]:8080 http2; - server_name localhost; - - # Set maximum allowed request size - client_max_body_size 4m; - - location / { - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - proxy_set_header X-NginX-Proxy true; - proxy_pass http://localhost:3000; - proxy_redirect off; - proxy_connect_timeout 5s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - } - } -} diff --git a/infra/message-box-server/package-lock.json b/infra/message-box-server/package-lock.json index 75a0a41a3..effe8262a 100644 --- a/infra/message-box-server/package-lock.json +++ b/infra/message-box-server/package-lock.json @@ -20,7 +20,6 @@ "@opentelemetry/exporter-logs-otlp-http": "^0.221.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.221.0", "@opentelemetry/exporter-trace-otlp-http": "^0.221.0", - "@opentelemetry/instrumentation": "^0.221.0", "@opentelemetry/instrumentation-runtime-node": "^0.34.0", "@opentelemetry/resources": "^2.10.0", "@opentelemetry/sdk-logs": "^0.221.0", @@ -34,13 +33,10 @@ "express-rate-limit": "^8.6.0", "firebase-admin": "^14.2.0", "knex": "^3.3.0", - "mongodb": "^7.5.0", "mysql2": "^3.23.1", "pino": "^10.3.1", - "prettyjson": "^1.2.5", "swagger-jsdoc": "^6.3.0", - "swagger-ui-express": "^5.0.1", - "web-push": "^3.6.7" + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", @@ -49,24 +45,19 @@ "@types/jest": "^30.0.0", "@types/mock-knex": "^0.4.8", "@types/node": "^24.13.3", - "@types/prettyjson": "^0.0.33", - "@types/superagent": "^8.1.11", "@types/supertest": "^7.2.1", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", - "@types/web-push": "^3.6.4", "axios": "^1.18.1", "axios-mock-adapter": "^2.1.0", "better-sqlite3": "^13.0.1", - "ejs": "^6.0.1", "jest": "^30.4.2", "mock-knex": "^0.4.13", "oxlint": "^1.75.0", + "prettier": "3.9.6", "rimraf": "^6.1.3", - "superagent": "^10.3.0", "supertest": "^7.2.2", "ts-jest": "^29.4.12", - "ts-node": "^10.9.2", "tsx": "^4.23.1", "typescript": "^6.0.3" }, @@ -747,30 +738,6 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -2016,15 +1983,6 @@ "url": "https://opencollective.com/js-sdsl" } }, - "node_modules/@mongodb-js/saslprep": { - "version": "1.4.11", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.11.tgz", - "integrity": "sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==", - "license": "MIT", - "dependencies": { - "sparse-bitfield": "^3.0.3" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -3932,34 +3890,6 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, - "node_modules/@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" - }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -4249,13 +4179,6 @@ "@types/pg": "*" } }, - "node_modules/@types/prettyjson": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/@types/prettyjson/-/prettyjson-0.0.33.tgz", - "integrity": "sha512-hHZMkavT9OXFq8p6pTCiaREtPxMRfy9NMp+Qa4PWH0RINQjyh0crOhoqUFA/cvIZncpjBpdvxkoe7nmVbyBJXw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -4410,31 +4333,6 @@ "license": "MIT", "optional": true }, - "node_modules/@types/web-push": { - "version": "3.6.4", - "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", - "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/webidl-conversions": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", - "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", - "license": "MIT" - }, - "node_modules/@types/whatwg-url": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", - "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", - "license": "MIT", - "dependencies": { - "@types/webidl-conversions": "*" - } - }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -4807,32 +4705,6 @@ "node": ">= 0.6" } }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -4960,13 +4832,6 @@ "license": "MIT", "optional": true }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT" - }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -4994,18 +4859,6 @@ "dev": true, "license": "MIT" }, - "node_modules/asn1.js": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", - "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0", - "safer-buffer": "^2.1.0" - } - }, "node_modules/async-retry": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", @@ -5268,12 +5121,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", - "license": "MIT" - }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -5367,15 +5214,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/bson": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-7.3.0.tgz", - "integrity": "sha512-WmjjMEwFwZHmGnAb7wn90MhkiT+mTm4x/rLj7dvAPWfwnVWDXhLun2e+UM88MJoDGW624yzZglVX/zTBy9ZZMw==", - "license": "Apache-2.0", - "engines": { - "node": ">=20.19.0" - } - }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -5667,15 +5505,6 @@ "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", "license": "MIT" }, - "node_modules/colors": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", - "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -5783,13 +5612,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5937,16 +5759,6 @@ "wrappy": "1" } }, - "node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -6020,19 +5832,6 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, - "node_modules/ejs": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz", - "integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "ejs": "bin/cli.js" - }, - "engines": { - "node": ">=0.12.18" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.376", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", @@ -7434,15 +7233,6 @@ "dev": true, "license": "MIT" }, - "node_modules/http_ece": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", - "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", - "license": "MIT", - "engines": { - "node": ">=16" - } - }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -9045,12 +8835,6 @@ "node": ">= 0.8" } }, - "node_modules/memory-pager": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "license": "MIT" - }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -9140,12 +8924,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -9216,65 +8994,6 @@ "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", "license": "MIT" }, - "node_modules/mongodb": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.5.0.tgz", - "integrity": "sha512-5FnrEDLnvp6ycUOGLNLLU33BfCx2qmp2mJjGPDwKLruYsVzXVSK5fsGpoDXvsXJwBfBsD7ebMRdawbDxC2814g==", - "license": "Apache-2.0", - "dependencies": { - "@mongodb-js/saslprep": "^1.4.11", - "bson": "^7.2.0", - "mongodb-connection-string-url": "^7.0.1" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.806.0", - "@mongodb-js/zstd": "^7.0.0", - "gcp-metadata": "^7.0.1", - "kerberos": "^7.0.0", - "mongodb-client-encryption": "^7.2.0", - "snappy": "^7.3.2", - "socks": "^2.8.6" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } - } - }, - "node_modules/mongodb-connection-string-url": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", - "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", - "license": "Apache-2.0", - "dependencies": { - "@types/whatwg-url": "^13.0.0", - "whatwg-url": "^14.1.0" - }, - "engines": { - "node": ">=20.19.0" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9991,6 +9710,22 @@ "node": ">=10" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", @@ -10020,19 +9755,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/prettyjson": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/prettyjson/-/prettyjson-1.2.5.tgz", - "integrity": "sha512-rksPWtoZb2ZpT5OVgtmy0KHVM+Dca3iVwWY9ifwhcexfjebtgjg3wmrUt9PvJ59XIYBcknQeYHD8IAnVlh9lAw==", - "license": "MIT", - "dependencies": { - "colors": "1.4.0", - "minimist": "^1.2.0" - }, - "bin": { - "prettyjson": "bin/prettyjson" - } - }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -10118,15 +9840,6 @@ "once": "^1.3.1" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/pure-rand": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", @@ -10769,15 +10482,6 @@ "source-map": "^0.6.0" } }, - "node_modules/sparse-bitfield": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", - "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", - "license": "MIT", - "dependencies": { - "memory-pager": "^1.0.2" - } - }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -11418,18 +11122,6 @@ "node": ">=0.6" } }, - "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/ts-jest": { "version": "29.4.12", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", @@ -11509,50 +11201,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -11763,13 +11411,6 @@ "uuid": "dist/esm/bin/uuid" } }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT" - }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -11804,47 +11445,6 @@ "makeerror": "1.0.12" } }, - "node_modules/web-push": { - "version": "3.6.7", - "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", - "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", - "license": "MPL-2.0", - "dependencies": { - "asn1.js": "^5.3.0", - "http_ece": "1.2.0", - "https-proxy-agent": "^7.0.0", - "jws": "^4.0.0", - "minimist": "^1.2.5" - }, - "bin": { - "web-push": "src/cli.js" - }, - "engines": { - "node": ">= 16" - } - }, - "node_modules/web-push/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/web-push/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -11854,15 +11454,6 @@ "node": ">= 8" } }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, "node_modules/websocket-driver": { "version": "0.7.5", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", @@ -11886,19 +11477,6 @@ "node": ">=0.8.0" } }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -12175,16 +11753,6 @@ "node": ">=8" } }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/infra/message-box-server/package.json b/infra/message-box-server/package.json index f6cecb37b..2df43c855 100644 --- a/infra/message-box-server/package.json +++ b/infra/message-box-server/package.json @@ -28,12 +28,13 @@ "dev": "nodemon --inspect --watch .env --watch src --exec tsx src/index.ts", "build": "tsc", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config=jest.config.mjs", - "test:integration": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config=jest.config.integration.ts", "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config=jest.config.mjs --coverage --coverageReporters=text --coverageReporters=html", "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config=jest.config.mjs --watch", + "format:check": "npx --no-install prettier --check \"**/*.{js,json,mjs,ts}\"", "lint": "oxlint src --deny-warnings", - "clean": "rimraf dist", - "prebuild": "rimraf dist" + "typecheck": "tsc --project tsconfig.json --noEmit --incremental false", + "clean": "rimraf out", + "prebuild": "rimraf out" }, "keywords": [], "author": "", @@ -46,24 +47,19 @@ "@types/jest": "^30.0.0", "@types/mock-knex": "^0.4.8", "@types/node": "^24.13.3", - "@types/prettyjson": "^0.0.33", - "@types/superagent": "^8.1.11", "@types/supertest": "^7.2.1", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", - "@types/web-push": "^3.6.4", "axios": "^1.18.1", "axios-mock-adapter": "^2.1.0", "better-sqlite3": "^13.0.1", - "ejs": "^6.0.1", "jest": "^30.4.2", "mock-knex": "^0.4.13", "oxlint": "^1.75.0", + "prettier": "3.9.6", "rimraf": "^6.1.3", - "superagent": "^10.3.0", "supertest": "^7.2.2", "ts-jest": "^29.4.12", - "ts-node": "^10.9.2", "tsx": "^4.23.1", "typescript": "^6.0.3" }, @@ -79,7 +75,6 @@ "@opentelemetry/exporter-logs-otlp-http": "^0.221.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.221.0", "@opentelemetry/exporter-trace-otlp-http": "^0.221.0", - "@opentelemetry/instrumentation": "^0.221.0", "@opentelemetry/instrumentation-runtime-node": "^0.34.0", "@opentelemetry/resources": "^2.10.0", "@opentelemetry/sdk-logs": "^0.221.0", @@ -93,12 +88,9 @@ "express-rate-limit": "^8.6.0", "firebase-admin": "^14.2.0", "knex": "^3.3.0", - "mongodb": "^7.5.0", "mysql2": "^3.23.1", "pino": "^10.3.1", - "prettyjson": "^1.2.5", "swagger-jsdoc": "^6.3.0", - "swagger-ui-express": "^5.0.1", - "web-push": "^3.6.7" + "swagger-ui-express": "^5.0.1" } } diff --git a/infra/message-box-server/scripts/build.js b/infra/message-box-server/scripts/build.js deleted file mode 100644 index d5668e15d..000000000 --- a/infra/message-box-server/scripts/build.js +++ /dev/null @@ -1,19 +0,0 @@ -const ejs = require('ejs') -const fs = require('fs') -require('dotenv').config() - -ejs.renderFile( - 'src/templates/documentation.ejs', - { - ...process.env, - routes: require('../src/routes').postAuthrite - }, - {}, - (err, res) => { - if (err) { - throw err - } - console.log('Generating API Documentation...') - fs.writeFileSync('public/index.html', res) - } -) diff --git a/infra/message-box-server/src/__tests/rateLimitPolicy.test.ts b/infra/message-box-server/src/__tests/rateLimitPolicy.test.ts index 3b12e221c..2f36e8d5a 100644 --- a/infra/message-box-server/src/__tests/rateLimitPolicy.test.ts +++ b/infra/message-box-server/src/__tests/rateLimitPolicy.test.ts @@ -6,10 +6,7 @@ import { rateLimitOptions } from '../security/rateLimitPolicy.js' -async function withServer ( - app: Express, - run: (url: string) => Promise -): Promise { +async function withServer(app: Express, run: (url: string) => Promise): Promise { const server = app.listen(0, '127.0.0.1') await new Promise((resolve, reject) => { server.once('listening', resolve) @@ -17,11 +14,12 @@ async function withServer ( }) try { const address = server.address() - if (address == null || typeof address === 'string') throw new Error('missing test server address') + if (address == null || typeof address === 'string') + throw new Error('missing test server address') await run(`http://127.0.0.1:${address.port}`) } finally { await new Promise((resolve, reject) => { - server.close(error => error == null ? resolve() : reject(error)) + server.close(error => (error == null ? resolve() : reject(error))) }) } } @@ -39,16 +37,24 @@ describe('rate-limit security policy', () => { } next() }) - app.use(rateLimit(rateLimitOptions( - 'TEST_RATE_LIMIT', - { windowMs: 60_000, limit: 1 }, - { keyGenerator: authenticatedIdentityKey } - ))) + app.use( + rateLimit( + rateLimitOptions( + 'TEST_RATE_LIMIT', + { windowMs: 60_000, limit: 1 }, + { keyGenerator: authenticatedIdentityKey } + ) + ) + ) app.get('/protected', (_req, res) => res.sendStatus(204)) await withServer(app, async url => { - expect((await fetch(`${url}/protected`, { headers: { 'x-test-identity': 'alice' } })).status).toBe(204) - expect((await fetch(`${url}/protected`, { headers: { 'x-test-identity': 'bob' } })).status).toBe(204) + expect( + (await fetch(`${url}/protected`, { headers: { 'x-test-identity': 'alice' } })).status + ).toBe(204) + expect( + (await fetch(`${url}/protected`, { headers: { 'x-test-identity': 'bob' } })).status + ).toBe(204) const rejected = await fetch(`${url}/protected`, { headers: { 'x-test-identity': 'alice' } }) expect(rejected.status).toBe(429) expect(rejected.headers.has('ratelimit')).toBe(true) @@ -68,9 +74,11 @@ describe('rate-limit security policy', () => { expect(() => configureTrustProxy(app, '11')).toThrow(/TRUST_PROXY_HOPS/) process.env.TEST_RATE_LIMIT_MAX = '0' - expect(() => rateLimitOptions('TEST_RATE_LIMIT', { - windowMs: 60_000, - limit: 1 - })).toThrow(/positive integer/) + expect(() => + rateLimitOptions('TEST_RATE_LIMIT', { + windowMs: 60_000, + limit: 1 + }) + ).toThrow(/positive integer/) }) }) diff --git a/infra/message-box-server/src/app.ts b/infra/message-box-server/src/app.ts index ad9776746..2bb5d8913 100644 --- a/infra/message-box-server/src/app.ts +++ b/infra/message-box-server/src/app.ts @@ -19,9 +19,7 @@ */ import * as dotenv from 'dotenv' -import express, { - Express -} from 'express' +import express, { Express } from 'express' import bodyParser from 'body-parser' import { Logger } from './utils/logger.js' import { Setup } from '@bsv/wallet-toolbox' @@ -32,10 +30,7 @@ import { createAuthMiddleware } from '@bsv/auth-express-middleware' import { rateLimit } from 'express-rate-limit' import { setupSwagger } from './swagger.js' import { bindMessageBoxRuntime } from './runtimeDeps.js' -import { - registerMessageBoxPreAuthRoutes, - registerMessageBoxPostAuthRoutes -} from './compose.js' +import { registerMessageBoxPreAuthRoutes, registerMessageBoxPostAuthRoutes } from './compose.js' import * as crypto from 'crypto' import { configureTrustProxy, rateLimitOptions } from './security/rateLimitPolicy.js' import { @@ -45,7 +40,7 @@ import { readBodyLimitBytes, securityHeaders } from './security/edgePolicy.js' -(global.self as any) = { crypto } +;(global.self as any) = { crypto } dotenv.config() @@ -71,20 +66,22 @@ if (NODE_ENV === 'development' || process.env.LOGGING_ENABLED === 'true') { /** * Knex instance connected based on environment (development, production, or staging). */ -export const knex: Knex = (knexLib as any).default?.( - NODE_ENV === 'production' || NODE_ENV === 'staging' - ? knexConfig.production - : knexConfig.development -) ?? (knexLib as any)( - NODE_ENV === 'production' || NODE_ENV === 'staging' - ? knexConfig.production - : knexConfig.development -) +export const knex: Knex = + (knexLib as any).default?.( + NODE_ENV === 'production' || NODE_ENV === 'staging' + ? knexConfig.production + : knexConfig.development + ) ?? + (knexLib as any)( + NODE_ENV === 'production' || NODE_ENV === 'staging' + ? knexConfig.production + : knexConfig.development + ) // Wallet initialization logic let _wallet: WalletInterface | undefined let _resolveReady: () => void -export const walletReady = new Promise((resolve) => { +export const walletReady = new Promise(resolve => { _resolveReady = resolve }) @@ -97,7 +94,7 @@ export const walletReady = new Promise((resolve) => { * @returns {Promise} Resolves when the wallet is initialized. * @throws If SERVER_PRIVATE_KEY is missing or invalid. */ -export async function initializeWallet (): Promise { +export async function initializeWallet(): Promise { if (SERVER_PRIVATE_KEY == null || SERVER_PRIVATE_KEY.trim() === '') { throw new Error('SERVER_PRIVATE_KEY is not defined in environment variables.') } @@ -119,7 +116,7 @@ export async function initializeWallet (): Promise { * @returns {Promise} The initialized wallet client * @throws {Error} If called before the wallet is initialized */ -export async function getWallet (): Promise { +export async function getWallet(): Promise { await walletReady if (_wallet == null) { throw new Error('Wallet has not been initialized yet.') @@ -139,7 +136,7 @@ export const appReady = (async () => { * * Steps: * - Applies JSON body parser - * - Enables CORS headers for all routes + * - Applies the configured public, allowlist, or disabled CORS policy * - Waits for WalletClient to initialize * - Adds authentication middleware * - Mounts pre-auth and post-auth route handlers @@ -147,24 +144,30 @@ export const appReady = (async () => { * @returns {Promise} Once all middleware and routes are mounted * @throws If wallet is not available when needed */ -export async function useRoutes (): Promise { - app.use(securityHeaders({ - environmentPrefix: 'MESSAGE_BOX', - contentSecurityPolicy: "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" - })) - app.use(corsPolicy({ - environmentPrefix: 'MESSAGE_BOX', - methods: ['GET', 'POST', 'OPTIONS'] - })) +export async function useRoutes(): Promise { + app.use( + securityHeaders({ + environmentPrefix: 'MESSAGE_BOX', + contentSecurityPolicy: + "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + }) + ) + app.use( + corsPolicy({ + environmentPrefix: 'MESSAGE_BOX', + methods: ['GET', 'POST', 'OPTIONS'] + }) + ) app.use(concurrencyLimit('MESSAGE_BOX', 200)) - app.use(rateLimit(rateLimitOptions( - 'MESSAGE_BOX_PRE_AUTH_RATE_LIMIT', - { windowMs: 60_000, limit: 300 } - ))) - app.use(bodyParser.json({ - limit: readBodyLimitBytes('MESSAGE_BOX', 4 * 1024 * 1024), - type: 'application/json' - })) + app.use( + rateLimit(rateLimitOptions('MESSAGE_BOX_PRE_AUTH_RATE_LIMIT', { windowMs: 60_000, limit: 300 })) + ) + app.use( + bodyParser.json({ + limit: readBodyLimitBytes('MESSAGE_BOX', 4 * 1024 * 1024), + type: 'application/json' + }) + ) app.use(bodyParserErrorHandler) // Enable Swagger docs @@ -184,13 +187,17 @@ export async function useRoutes (): Promise { }) ) - registerMessageBoxPostAuthRoutes(app, { - wallet: _wallet, - calculateRequestPrice: async (req) => { - if (req.url.includes('/sendMessage')) { - // TODO: Configure a custom price calculation as needed. + registerMessageBoxPostAuthRoutes( + app, + { + wallet: _wallet, + calculateRequestPrice: async req => { + if (req.url.includes('/sendMessage')) { + // TODO: Configure a custom price calculation as needed. + } + return 0 } - return 0 - } - }, ROUTING_PREFIX) + }, + ROUTING_PREFIX + ) } diff --git a/infra/message-box-server/src/compose.ts b/infra/message-box-server/src/compose.ts index a50e7b796..51ea89262 100644 --- a/infra/message-box-server/src/compose.ts +++ b/infra/message-box-server/src/compose.ts @@ -20,14 +20,8 @@ import sendMessageRoute from './routes/sendMessage.js' import { Logger } from './utils/logger.js' import { bindMessageBoxRuntime } from './runtimeDeps.js' import type { MessageBoxContext } from './context.js' -import { - authenticatedIdentityKey, - rateLimitOptions -} from './security/rateLimitPolicy.js' -import { - readCorsOriginSetting, - readBodyLimitBytes -} from './security/edgePolicy.js' +import { authenticatedIdentityKey, rateLimitOptions } from './security/rateLimitPolicy.js' +import { readCorsOriginSetting, readBodyLimitBytes } from './security/edgePolicy.js' import { authenticatedWebSocketIdentity, isIdentityOwnedRoom, @@ -45,15 +39,15 @@ type HttpMethod = 'get' | 'post' | 'put' | 'delete' /** Express app or router — embed mounts pieces on whichever it owns. */ export type MessageBoxRouter = IRouter -export function createMessageBoxApp (): Express { +export function createMessageBoxApp(): Express { return express() } -export function registerMessageBoxPreAuthRoutes ( +export function registerMessageBoxPreAuthRoutes( router: MessageBoxRouter, routingPrefix: string = '' ): void { - preAuth.forEach((route) => { + preAuth.forEach(route => { router[route.type as HttpMethod]( `${routingPrefix}${route.path}`, route.func as unknown as (req: ExpressRequest, res: Response, next: NextFunction) => void @@ -62,33 +56,40 @@ export function registerMessageBoxPreAuthRoutes ( } /** Payment middleware (after auth) + postAuth route handlers. */ -export function registerMessageBoxPostAuthRoutes ( +export function registerMessageBoxPostAuthRoutes( router: MessageBoxRouter, ctx: Pick, routingPrefix: string = '', authenticatedRateLimitOptions: Partial = {} ): void { - router.use(rateLimit(rateLimitOptions( - 'MESSAGE_BOX_AUTHENTICATED_RATE_LIMIT', - { windowMs: 60_000, limit: 1_000 }, - { - keyGenerator: authenticatedIdentityKey, - ...authenticatedRateLimitOptions - } - ))) + router.use( + rateLimit( + rateLimitOptions( + 'MESSAGE_BOX_AUTHENTICATED_RATE_LIMIT', + { windowMs: 60_000, limit: 1_000 }, + { + keyGenerator: authenticatedIdentityKey, + ...authenticatedRateLimitOptions + } + ) + ) + ) router.use( createPaymentMiddleware({ wallet: ctx.wallet, - calculateRequestPrice: async (req) => + calculateRequestPrice: async req => await Promise.resolve(ctx.calculateRequestPrice(req as unknown as ExpressRequest)) }) ) - postAuth.forEach((route) => { + postAuth.forEach(route => { const method = route.type as HttpMethod if (route.path === '/sendMessage') { - router[method](`${routingPrefix}${route.path}`, sendMessageRoute.func as unknown as RequestHandler) + router[method]( + `${routingPrefix}${route.path}`, + sendMessageRoute.func as unknown as RequestHandler + ) } else { router[method](`${routingPrefix}${route.path}`, route.func as RequestHandler) } @@ -100,7 +101,7 @@ export function registerMessageBoxPostAuthRoutes ( * Same logic as standalone index.ts start(), with ctx.knex/ctx.wallet * instead of module singletons. */ -export function attachMessageBoxWebSockets ( +export function attachMessageBoxWebSockets( httpServer: HttpServer, ctx: MessageBoxContext ): AuthSocketServer | null { @@ -125,7 +126,7 @@ export function attachMessageBoxWebSockets ( const authenticatedSockets = new Map() const connectedSockets = new Map() - io.on('connection', (socket) => { + io.on('connection', socket => { connectedSockets.set(socket.id, socket) Logger.log('[WEBSOCKET] New connection established.') @@ -154,10 +155,7 @@ export function attachMessageBoxWebSockets ( if (identityKeyHandled) return try { - const identityKey = authenticatedWebSocketIdentity( - socket.identityKey, - data?.identityKey - ) + const identityKey = authenticatedWebSocketIdentity(socket.identityKey, data?.identityKey) authenticatedSockets.set(socket.id, identityKey) identityKeyHandled = true @@ -170,9 +168,10 @@ export function attachMessageBoxWebSockets ( } catch (error) { Logger.warn('[WEBSOCKET] Rejected an invalid authenticated peer or identity claim.') await socket.emit('authenticationFailed', { - reason: error instanceof WebSocketPolicyError - ? error.reason - : 'Invalid authenticated identity key' + reason: + error instanceof WebSocketPolicyError + ? error.reason + : 'Invalid authenticated identity key' }) } } @@ -184,7 +183,10 @@ export function attachMessageBoxWebSockets ( // Handle sendMessage over WebSocket socket.on( 'sendMessage', - async (data: { roomId: string, message: { messageId: string, recipient: string, body: string } }): Promise => { + async (data: { + roomId: string + message: { messageId: string; recipient: string; body: string } + }): Promise => { if (typeof data !== 'object' || data == null) { Logger.error('[WEBSOCKET ERROR] Invalid data object received.') await socket.emit('messageFailed', { reason: 'Invalid data object' }) @@ -195,7 +197,9 @@ export function attachMessageBoxWebSockets ( if (!authenticatedSockets.has(socket.id)) { Logger.warn('[WEBSOCKET] Unauthorized attempt to send a message.') - await socket.emit('paymentFailed', { reason: 'Unauthorized: WebSocket not authenticated' }) + await socket.emit('paymentFailed', { + reason: 'Unauthorized: WebSocket not authenticated' + }) return } @@ -229,7 +233,9 @@ export function attachMessageBoxWebSockets ( const messageBoxType = messageBoxFromRecipientRoom(message.recipient, roomId) if (messageBoxType == null) { - await socket.emit('messageFailed', { reason: 'Room does not match recipient and message box' }) + await socket.emit('messageFailed', { + reason: 'Room does not match recipient and message box' + }) return } @@ -249,17 +255,20 @@ export function attachMessageBoxWebSockets ( return routeResponse } } as unknown as Response - await sendMessageRoute.func({ - auth: { identityKey: authenticatedSockets.get(socket.id) }, - body: { - message: { - messageId: message.messageId, - recipient: message.recipient, - messageBox: messageBoxType, - body: message.body + await sendMessageRoute.func( + { + auth: { identityKey: authenticatedSockets.get(socket.id) }, + body: { + message: { + messageId: message.messageId, + recipient: message.recipient, + messageBox: messageBoxType, + body: message.body + } } - } - } as any, routeResponse) + } as any, + routeResponse + ) if (routeStatus !== 200 || routeBody?.status !== 'success') { await socket.emit(`sendMessageAck-${roomId}`, { @@ -277,15 +286,19 @@ export function attachMessageBoxWebSockets ( const recipientSockets = recipientSocketIds(authenticatedSockets, message.recipient) .map(socketId => connectedSockets.get(socketId)) .filter(recipientSocket => recipientSocket != null) - await Promise.all(recipientSockets.map(async recipientSocket => { - await recipientSocket.emit(`sendMessage-${roomId}`, { - sender: authenticatedSockets.get(socket.id), - messageId: message.messageId, - body: message.body + await Promise.all( + recipientSockets.map(async recipientSocket => { + await recipientSocket.emit(`sendMessage-${roomId}`, { + sender: authenticatedSockets.get(socket.id), + messageId: message.messageId, + body: message.body + }) }) - })) + ) const recipientConnections = recipientSockets.length - Logger.log(`[WEBSOCKET] Delivered message notification to ${recipientConnections} authenticated recipient connection(s).`) + Logger.log( + `[WEBSOCKET] Delivered message notification to ${recipientConnections} authenticated recipient connection(s).` + ) } catch (error) { Logger.error('[WEBSOCKET ERROR] Unexpected failure in sendMessage handler:', error) await socket.emit('messageFailed', { reason: 'Unexpected error occurred' }) @@ -309,7 +322,7 @@ export function attachMessageBoxWebSockets ( const identityKey = authenticatedSockets.get(socket.id) if (identityKey == null || !isIdentityOwnedRoom(identityKey, roomId)) { - Logger.warn('[WEBSOCKET] Rejected an attempt to join another identity\'s room.') + Logger.warn("[WEBSOCKET] Rejected an attempt to join another identity's room.") await socket.emit('joinFailed', { reason: 'Room is not owned by authenticated identity' }) return } @@ -333,7 +346,7 @@ export function attachMessageBoxWebSockets ( const identityKey = authenticatedSockets.get(socket.id) if (identityKey == null || !isIdentityOwnedRoom(identityKey, roomId)) { - Logger.warn('[WEBSOCKET] Rejected an attempt to leave another identity\'s room.') + Logger.warn("[WEBSOCKET] Rejected an attempt to leave another identity's room.") await socket.emit('leaveFailed', { reason: 'Room is not owned by authenticated identity' }) return } diff --git a/infra/message-box-server/src/config/__tests/runtime.test.ts b/infra/message-box-server/src/config/__tests/runtime.test.ts new file mode 100644 index 000000000..ad0bacc6f --- /dev/null +++ b/infra/message-box-server/src/config/__tests/runtime.test.ts @@ -0,0 +1,18 @@ +import { resolveHttpPort } from '../runtime.js' + +describe('resolveHttpPort', () => { + it('defaults to the container-facing port', () => { + expect(resolveHttpPort({})).toBe(8080) + }) + + it('prefers PORT and retains HTTP_PORT as a fallback', () => { + expect(resolveHttpPort({ PORT: '9000', HTTP_PORT: '9001' })).toBe(9000) + expect(resolveHttpPort({ HTTP_PORT: '9001' })).toBe(9001) + }) + + it.each(['0', '65536', '1.5', 'abc'])('rejects invalid PORT=%s', value => { + expect(() => resolveHttpPort({ PORT: value })).toThrow( + 'PORT must be an integer between 1 and 65535.' + ) + }) +}) diff --git a/infra/message-box-server/src/config/firebase.ts b/infra/message-box-server/src/config/firebase.ts index ff2e64257..bccb893fa 100644 --- a/infra/message-box-server/src/config/firebase.ts +++ b/infra/message-box-server/src/config/firebase.ts @@ -1,4 +1,11 @@ -import { initializeApp, getApps, getApp, cert, applicationDefault, type App } from 'firebase-admin/app' +import { + initializeApp, + getApps, + getApp, + cert, + applicationDefault, + type App +} from 'firebase-admin/app' import { getMessaging, type Messaging, type Message } from 'firebase-admin/messaging' import { getFirestore, type Firestore } from 'firebase-admin/firestore' import * as path from 'path' @@ -18,7 +25,10 @@ export function initializeFirebase(): App | null { const enableFirebase = process.env.ENABLE_FIREBASE if (enableFirebase !== 'true') { - log.info({ operation: 'firebase.init', enable_firebase: enableFirebase ?? 'unset' }, 'Firebase disabled, skipping initialization') + log.info( + { operation: 'firebase.init', enable_firebase: enableFirebase ?? 'unset' }, + 'Firebase disabled, skipping initialization' + ) return null } @@ -39,38 +49,73 @@ export function initializeFirebase(): App | null { let firebaseCredential: any // Will be assigned based on auth method if (serviceAccountJson != null && serviceAccountJson !== '') { - log.info({ operation: 'firebase.init', credential_source: 'env' }, 'Using Firebase service account from environment variable') + log.info( + { operation: 'firebase.init', credential_source: 'env' }, + 'Using Firebase service account from environment variable' + ) try { - log.debug({ operation: 'firebase.init', service_account_json_length: serviceAccountJson.length }, 'Service account JSON length') + log.debug( + { operation: 'firebase.init', service_account_json_length: serviceAccountJson.length }, + 'Service account JSON length' + ) // Debug credential functions - log.debug({ operation: 'firebase.init', cert_function_type: typeof cert }, 'cert function type') - log.debug({ operation: 'firebase.init', application_default_function_type: typeof applicationDefault }, 'applicationDefault function type') + log.debug( + { operation: 'firebase.init', cert_function_type: typeof cert }, + 'cert function type' + ) + log.debug( + { + operation: 'firebase.init', + application_default_function_type: typeof applicationDefault + }, + 'applicationDefault function type' + ) const serviceAccount = JSON.parse(serviceAccountJson) - log.debug({ operation: 'firebase.init', service_account_keys: Object.keys(serviceAccount ?? {}) }, 'Parsed service account keys') + log.debug( + { operation: 'firebase.init', service_account_keys: Object.keys(serviceAccount ?? {}) }, + 'Parsed service account keys' + ) if (serviceAccount == null || typeof serviceAccount !== 'object') { throw new Error('Parsed service account is not a valid object') } - if (serviceAccount.private_key == null || serviceAccount.client_email == null || serviceAccount.project_id == null) { - throw new Error('Service account missing required fields (private_key, client_email, project_id)') + if ( + serviceAccount.private_key == null || + serviceAccount.client_email == null || + serviceAccount.project_id == null + ) { + throw new Error( + 'Service account missing required fields (private_key, client_email, project_id)' + ) } firebaseCredential = cert(serviceAccount) log.info({ operation: 'firebase.init' }, 'Firebase credential created successfully') } catch (parseError) { - log.error({ operation: 'firebase.init', outcome: 'error', err: parseError }, 'Firebase service account parsing failed') - throw new Error(`Failed to parse FIREBASE_SERVICE_ACCOUNT_JSON: ${(parseError instanceof Error) ? parseError.message : 'Invalid JSON'}`) + log.error( + { operation: 'firebase.init', outcome: 'error', err: parseError }, + 'Firebase service account parsing failed' + ) + throw new Error( + `Failed to parse FIREBASE_SERVICE_ACCOUNT_JSON: ${parseError instanceof Error ? parseError.message : 'Invalid JSON'}` + ) } } else if (serviceAccountPath != null && serviceAccountPath !== '') { - log.info({ operation: 'firebase.init', credential_source: 'file' }, 'Using Firebase service account key file') + log.info( + { operation: 'firebase.init', credential_source: 'file' }, + 'Using Firebase service account key file' + ) const absolutePath = path.resolve(process.cwd(), serviceAccountPath) // eslint-disable-next-line @typescript-eslint/no-var-requires firebaseCredential = cert(require(absolutePath)) } else { - log.info({ operation: 'firebase.init', credential_source: 'default' }, 'Using Firebase default credentials') + log.info( + { operation: 'firebase.init', credential_source: 'default' }, + 'Using Firebase default credentials' + ) firebaseCredential = applicationDefault() } @@ -87,7 +132,10 @@ export function initializeFirebase(): App | null { log.info({ operation: 'firebase.init' }, 'Firebase Admin SDK initialized successfully') return firebaseApp } catch (error) { - log.error({ operation: 'firebase.init', outcome: 'error', err: error }, 'Firebase initialization failed') + log.error( + { operation: 'firebase.init', outcome: 'error', err: error }, + 'Firebase initialization failed' + ) throw error } } @@ -107,24 +155,22 @@ export function getFirebaseMessaging(): Messaging | null { */ export function getFirebaseFirestore(): Firestore { if (firebaseApp == null) { - throw new Error( - 'Firebase not initialized. Call initializeFirebase() first.' - ) + throw new Error('Firebase not initialized. Call initializeFirebase() first.') } return getFirestore(firebaseApp) } interface FCMPayload { - title: string; - body: string; - icon?: string; - badge?: number; - data?: Record; + title: string + body: string + icon?: string + badge?: number + data?: Record } interface SendNotificationResult { - success: boolean; - messageId: string; + success: boolean + messageId: string } /** @@ -132,7 +178,7 @@ interface SendNotificationResult { */ export async function sendNotification( fcmToken: string, - payload: FCMPayload, + payload: FCMPayload ): Promise { try { const messaging = getFirebaseMessaging() @@ -146,38 +192,44 @@ export async function sendNotification( notification: { title: payload.title, body: payload.body, - ...(payload.icon && { imageUrl: payload.icon }), + ...(payload.icon && { imageUrl: payload.icon }) }, data: payload.data || {}, android: { - priority: "high", + priority: 'high', notification: { - clickAction: "OPEN_ACTIVITY_1", - ...(payload.badge && { notificationCount: payload.badge }), - }, + clickAction: 'OPEN_ACTIVITY_1', + ...(payload.badge && { notificationCount: payload.badge }) + } }, apns: { headers: { - "apns-priority": "10", + 'apns-priority': '10' }, payload: { aps: { alert: { title: payload.title, - body: payload.body, + body: payload.body }, - sound: "default", - badge: payload.badge || 1, - }, - }, - }, - }; + sound: 'default', + badge: payload.badge || 1 + } + } + } + } - const response = await messaging.send(message); - log.info({ operation: 'firebase.send_notification', message_id: response }, 'Notification sent successfully') - return { success: true, messageId: response }; + const response = await messaging.send(message) + log.info( + { operation: 'firebase.send_notification', message_id: response }, + 'Notification sent successfully' + ) + return { success: true, messageId: response } } catch (error) { - log.error({ operation: 'firebase.send_notification', outcome: 'error', err: error }, 'Failed to send notification') - throw error; + log.error( + { operation: 'firebase.send_notification', outcome: 'error', err: error }, + 'Failed to send notification' + ) + throw error } } diff --git a/infra/message-box-server/src/config/runtime.ts b/infra/message-box-server/src/config/runtime.ts new file mode 100644 index 000000000..bb97fd057 --- /dev/null +++ b/infra/message-box-server/src/config/runtime.ts @@ -0,0 +1,32 @@ +const DEFAULT_HTTP_PORT = 8080 +const MAX_TCP_PORT = 65_535 + +interface HttpPortEnvironment { + PORT?: string + HTTP_PORT?: string +} + +function parsePort(value: string | undefined, name: string): number | undefined { + if (value == null || value.trim() === '') return undefined + if (!/^[0-9]+$/.test(value.trim())) { + throw new Error(`${name} must be an integer between 1 and ${MAX_TCP_PORT}.`) + } + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > MAX_TCP_PORT) { + throw new Error(`${name} must be an integer between 1 and ${MAX_TCP_PORT}.`) + } + return parsed +} + +/** + * PORT is the cloud/container convention. HTTP_PORT remains a compatibility + * fallback for existing operators. The service itself listens directly on the + * selected port; there is no hidden in-container reverse proxy. + */ +export function resolveHttpPort(environment: HttpPortEnvironment = process.env): number { + return ( + parsePort(environment.PORT, 'PORT') ?? + parsePort(environment.HTTP_PORT, 'HTTP_PORT') ?? + DEFAULT_HTTP_PORT + ) +} diff --git a/infra/message-box-server/src/context.ts b/infra/message-box-server/src/context.ts index 4028cbc87..70c5376cf 100644 --- a/infra/message-box-server/src/context.ts +++ b/infra/message-box-server/src/context.ts @@ -22,9 +22,7 @@ export interface CreateMessageBoxContextOptions { logger?: Console } -export function createMessageBoxContext ( - deps: CreateMessageBoxContextOptions -): MessageBoxContext { +export function createMessageBoxContext(deps: CreateMessageBoxContextOptions): MessageBoxContext { if (deps.wallet == null) { throw new Error('createMessageBoxContext requires a wallet') } @@ -38,12 +36,14 @@ export function createMessageBoxContext ( routingPrefix: deps.routingPrefix ?? '', enableWebSockets: deps.enableWebSockets ?? true, enableSwagger: deps.enableSwagger ?? true, - calculateRequestPrice: deps.calculateRequestPrice ?? (async (req: Request) => { - if (req.url.includes('/sendMessage')) { - // configurable via deps.calculateRequestPrice - } - return 0 - }), + calculateRequestPrice: + deps.calculateRequestPrice ?? + (async (req: Request) => { + if (req.url.includes('/sendMessage')) { + // configurable via deps.calculateRequestPrice + } + return 0 + }), logger: deps.logger ?? console } } diff --git a/infra/message-box-server/src/index.ts b/infra/message-box-server/src/index.ts index a0114a3eb..a5cfad06f 100644 --- a/infra/message-box-server/src/index.ts +++ b/infra/message-box-server/src/index.ts @@ -21,45 +21,36 @@ import { app, appReady, getWallet, knex } from './app.js' import { createServer } from 'http' import { Logger, log } from './utils/logger.js' import { trace, SpanStatusCode } from '@opentelemetry/api' -import { - createMessageBoxContext, - attachMessageBoxWebSockets -} from './compose.js' +import { createMessageBoxContext, attachMessageBoxWebSockets } from './compose.js' import { AuthSocketServer } from '@bsv/authsocket' import * as crypto from 'crypto' import { initializeFirebase } from './config/firebase.js' import { configureHttpServer } from './security/edgePolicy.js' -(global.self as any) = { crypto } +import { resolveHttpPort } from './config/runtime.js' +;(global.self as any) = { crypto } dotenv.config() // Load environment variables const { NODE_ENV = 'development', - PORT, SERVER_PRIVATE_KEY, ENABLE_WEBSOCKETS = 'true', ROUTING_PREFIX = '' } = process.env -// if (NODE_ENV === 'development' || process.env.LOGGING_ENABLED === 'true') { -// Logger.enable() -// } -Logger.enable() -// Determine which port to listen on -const parsedPort = Number(PORT) -const parsedEnvPort = Number(process.env.HTTP_PORT) - -const HTTP_PORT: number = NODE_ENV !== 'development' - ? 3000 - : !Number.isNaN(parsedPort) && parsedPort > 0 - ? parsedPort - : !Number.isNaN(parsedEnvPort) && parsedEnvPort > 0 - ? parsedEnvPort - : 8080 +if (NODE_ENV === 'development' || process.env.LOGGING_ENABLED === 'true') { + Logger.enable() +} + +const HTTP_PORT = resolveHttpPort() // Ensure private key is available before proceeding -if (SERVER_PRIVATE_KEY === undefined || SERVER_PRIVATE_KEY === null || SERVER_PRIVATE_KEY.trim() === '') { +if ( + SERVER_PRIVATE_KEY === undefined || + SERVER_PRIVATE_KEY === null || + SERVER_PRIVATE_KEY.trim() === '' +) { throw new Error('SERVER_PRIVATE_KEY is not defined in the environment variables.') } @@ -113,31 +104,55 @@ export const start = async (): Promise => { // `ioRef` is a const container; the live WebSocket server is at `ioRef.current`. export { ioRef as io, http, HTTP_PORT, ROUTING_PREFIX } -// Only run server if not in test mode -if (NODE_ENV !== 'test') { +export async function startStandalone(): Promise { const tracer = trace.getTracer('@bsv/messagebox-server') - http.listen(HTTP_PORT, () => { - log.info({ operation: 'listen', outcome: 'ok', port: HTTP_PORT }, 'MessageBox listening') - - // Run DB migrations immediately, no delay needed with container healthchecks - ; tracer.startActiveSpan('messagebox.migrate', async (span) => { - const startedAt = Date.now() - try { - await knex.migrate.latest() - span.setStatus({ code: SpanStatusCode.OK }) - log.info({ operation: 'migrate', outcome: 'ok', duration_ms: Date.now() - startedAt }, 'migrations applied') - } catch (error) { - span.recordException(error as Error) - span.setStatus({ code: SpanStatusCode.ERROR, message: (error as Error).message }) - log.error({ operation: 'migrate', outcome: 'error', err: error }, '[STARTUP ERROR] migrations failed') - } finally { - span.end() - } + await tracer.startActiveSpan('messagebox.migrate', async span => { + const startedAt = Date.now() + try { + await knex.migrate.latest() + span.setStatus({ code: SpanStatusCode.OK }) + log.info( + { operation: 'migrate', outcome: 'ok', duration_ms: Date.now() - startedAt }, + 'migrations applied' + ) + } catch (error) { + span.recordException(error as Error) + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : 'Migration failed' }) + throw error + } finally { + span.end() + } }) - start().catch(error => { + await start() + await new Promise((resolve, reject) => { + http.once('error', reject) + http.listen(HTTP_PORT, () => { + http.off('error', reject) + log.info({ operation: 'listen', outcome: 'ok', port: HTTP_PORT }, 'MessageBox listening') + resolve() + }) + }) +} + +// Migrations, wallet/auth setup, and WebSocket policy must all be ready before +// the service accepts traffic. A failed prerequisite is a failed process, not +// a partially healthy server. +if (NODE_ENV !== 'test') { + void startStandalone().catch(async error => { log.error({ operation: 'server.init', outcome: 'error', err: error }, '[SERVER INIT ERROR]') + try { + await knex.destroy() + } catch (shutdownError) { + log.error( + { operation: 'server.shutdown', outcome: 'error', err: shutdownError }, + 'Failed to close the database pool after initialization failure' + ) + } + process.exitCode = 1 }) } diff --git a/infra/message-box-server/src/migrations/2022-12-28-001-initial-migration.ts b/infra/message-box-server/src/migrations/2022-12-28-001-initial-migration.ts index 668c6cb81..80ee81549 100644 --- a/infra/message-box-server/src/migrations/2022-12-28-001-initial-migration.ts +++ b/infra/message-box-server/src/migrations/2022-12-28-001-initial-migration.ts @@ -1,7 +1,7 @@ import { Knex } from 'knex' -export async function up (knex: Knex): Promise { - await knex.schema.createTable('messageBox', (table) => { +export async function up(knex: Knex): Promise { + await knex.schema.createTable('messageBox', table => { table.increments('messageBoxId').primary() table.timestamps(true, true) table.string('type').notNullable() // What type of messages go in here? @@ -9,7 +9,7 @@ export async function up (knex: Knex): Promise { table.unique(['type', 'identityKey']) }) - await knex.schema.createTable('messages', (table) => { + await knex.schema.createTable('messages', table => { table.increments('messageId').primary() table.timestamps(true, true) table @@ -25,7 +25,7 @@ export async function up (knex: Knex): Promise { }) } -export async function down (knex: Knex): Promise { +export async function down(knex: Knex): Promise { await knex.schema.dropTableIfExists('messages') await knex.schema.dropTableIfExists('messageBox') } diff --git a/infra/message-box-server/src/migrations/2023-01-17-messages-update.ts b/infra/message-box-server/src/migrations/2023-01-17-messages-update.ts index 3466ab73e..83cac4a3c 100644 --- a/infra/message-box-server/src/migrations/2023-01-17-messages-update.ts +++ b/infra/message-box-server/src/migrations/2023-01-17-messages-update.ts @@ -1,13 +1,13 @@ import { Knex } from 'knex' -export async function up (knex: Knex): Promise { - await knex.schema.alterTable('messages', (table) => { +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('messages', table => { table.dropColumn('acknowledged') }) } -export async function down (knex: Knex): Promise { - await knex.schema.alterTable('messages', (table) => { +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('messages', table => { table.boolean('acknowledged').defaultTo(false) }) } diff --git a/infra/message-box-server/src/migrations/2024-03-05-001-messageID-upgrade.ts b/infra/message-box-server/src/migrations/2024-03-05-001-messageID-upgrade.ts index 8c5ba23c8..d5a4fcbfc 100644 --- a/infra/message-box-server/src/migrations/2024-03-05-001-messageID-upgrade.ts +++ b/infra/message-box-server/src/migrations/2024-03-05-001-messageID-upgrade.ts @@ -1,25 +1,25 @@ import { Knex } from 'knex' -export async function up (knex: Knex): Promise { +export async function up(knex: Knex): Promise { // Drop the existing 'messageId' column - await knex.schema.alterTable('messages', (table) => { + await knex.schema.alterTable('messages', table => { table.dropColumn('messageId') }) // Add 'messageId' back as a unique string - await knex.schema.alterTable('messages', (table) => { + await knex.schema.alterTable('messages', table => { table.string('messageId').unique().notNullable() }) } -export async function down (knex: Knex): Promise { +export async function down(knex: Knex): Promise { // Drop the new 'messageId' column - await knex.schema.alterTable('messages', (table) => { + await knex.schema.alterTable('messages', table => { table.dropColumn('messageId') }) // Restore 'messageId' as an auto-incrementing primary key - await knex.schema.alterTable('messages', (table) => { + await knex.schema.alterTable('messages', table => { table.increments('messageId').primary() }) } diff --git a/infra/message-box-server/src/migrations/2025-01-31-001-notification-permissions.ts b/infra/message-box-server/src/migrations/2025-01-31-001-notification-permissions.ts index f3ec3c852..150619b22 100644 --- a/infra/message-box-server/src/migrations/2025-01-31-001-notification-permissions.ts +++ b/infra/message-box-server/src/migrations/2025-01-31-001-notification-permissions.ts @@ -2,7 +2,7 @@ import { Knex } from 'knex' export async function up(knex: Knex): Promise { // Create generalized message permissions table for all box/sender combinations - await knex.schema.createTable('message_permissions', (table) => { + await knex.schema.createTable('message_permissions', table => { table.increments('id').primary() table.timestamp('created_at').defaultTo(knex.fn.now()) table.timestamp('updated_at').defaultTo(knex.fn.now()) @@ -20,7 +20,7 @@ export async function up(knex: Knex): Promise { }) // Create server fee configuration table - await knex.schema.createTable('server_fees', (table) => { + await knex.schema.createTable('server_fees', table => { table.increments('id').primary() table.timestamp('created_at').defaultTo(knex.fn.now()) table.timestamp('updated_at').defaultTo(knex.fn.now()) diff --git a/infra/message-box-server/src/migrations/2025-01-31-002-device-registrations.ts b/infra/message-box-server/src/migrations/2025-01-31-002-device-registrations.ts index 6457c42f9..e120d8aa8 100644 --- a/infra/message-box-server/src/migrations/2025-01-31-002-device-registrations.ts +++ b/infra/message-box-server/src/migrations/2025-01-31-002-device-registrations.ts @@ -2,7 +2,7 @@ import { Knex } from 'knex' export async function up(knex: Knex): Promise { // Create device registrations table for FCM push notifications - await knex.schema.createTable('device_registrations', (table) => { + await knex.schema.createTable('device_registrations', table => { table.increments('id').primary() table.timestamp('created_at').defaultTo(knex.fn.now()) table.timestamp('updated_at').defaultTo(knex.fn.now()) diff --git a/infra/message-box-server/src/migrations/2026-07-26-001-message-permission-scope.ts b/infra/message-box-server/src/migrations/2026-07-26-001-message-permission-scope.ts new file mode 100644 index 000000000..12899b1ec --- /dev/null +++ b/infra/message-box-server/src/migrations/2026-07-26-001-message-permission-scope.ts @@ -0,0 +1,46 @@ +import { Knex } from 'knex' + +/** + * SQL UNIQUE constraints treat NULL values as distinct. The historical + * (recipient, sender, message_box) key therefore allowed duplicate box-wide + * permissions where sender is NULL. A normalized, non-null scope closes that + * gap without changing the public sender representation. + */ +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('message_permissions', table => { + table.string('sender_scope', 255).notNullable().defaultTo('') + }) + + await knex('message_permissions').update({ + sender_scope: knex.raw("COALESCE(sender, '')") + }) + + const rows = await knex('message_permissions') + .select('id', 'recipient', 'sender_scope', 'message_box') + .orderBy('updated_at', 'desc') + .orderBy('id', 'desc') + + const seen = new Set() + const duplicateIds: number[] = [] + for (const row of rows) { + const key = JSON.stringify([row.recipient, row.sender_scope, row.message_box]) + if (seen.has(key)) duplicateIds.push(row.id) + else seen.add(key) + } + if (duplicateIds.length > 0) { + await knex('message_permissions').whereIn('id', duplicateIds).del() + } + + await knex.schema.alterTable('message_permissions', table => { + table.dropUnique(['recipient', 'sender', 'message_box']) + table.unique(['recipient', 'message_box', 'sender_scope']) + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('message_permissions', table => { + table.dropUnique(['recipient', 'message_box', 'sender_scope']) + table.unique(['recipient', 'sender', 'message_box']) + table.dropColumn('sender_scope') + }) +} diff --git a/infra/message-box-server/src/migrations/2026-07-26-002-list-query-indexes.ts b/infra/message-box-server/src/migrations/2026-07-26-002-list-query-indexes.ts new file mode 100644 index 000000000..4639396b0 --- /dev/null +++ b/infra/message-box-server/src/migrations/2026-07-26-002-list-query-indexes.ts @@ -0,0 +1,31 @@ +import { Knex } from 'knex' + +/** + * Keep the bounded list endpoints efficient as their tables grow. Column order + * mirrors the equality filters first and deterministic page ordering second. + */ +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('messages', table => { + table.index( + ['recipient', 'messageBoxId', 'created_at', 'messageId'], + 'messages_recipient_box_page_index' + ) + }) + + await knex.schema.alterTable('device_registrations', table => { + table.index(['identity_key', 'updated_at'], 'device_registrations_identity_updated_index') + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('device_registrations', table => { + table.dropIndex(['identity_key', 'updated_at'], 'device_registrations_identity_updated_index') + }) + + await knex.schema.alterTable('messages', table => { + table.dropIndex( + ['recipient', 'messageBoxId', 'created_at', 'messageId'], + 'messages_recipient_box_page_index' + ) + }) +} diff --git a/infra/message-box-server/src/migrations/__tests/listQueryIndexes.test.ts b/infra/message-box-server/src/migrations/__tests/listQueryIndexes.test.ts new file mode 100644 index 000000000..4318cda0a --- /dev/null +++ b/infra/message-box-server/src/migrations/__tests/listQueryIndexes.test.ts @@ -0,0 +1,57 @@ +import knexFactory, { type Knex } from 'knex' +import { + down as removeListQueryIndexes, + up as addListQueryIndexes +} from '../2026-07-26-002-list-query-indexes.js' + +async function indexNames(database: Knex, table: string): Promise { + const result = await database.raw(`PRAGMA index_list("${table}")`) + return result.map((index: { name: string }) => index.name) +} + +describe('list query indexes migration', () => { + let database: Knex + + beforeEach(async () => { + database = knexFactory({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true + }) + await database.schema.createTable('messages', table => { + table.string('messageId').primary() + table.string('recipient').notNullable() + table.integer('messageBoxId').notNullable() + table.timestamp('created_at').notNullable() + }) + await database.schema.createTable('device_registrations', table => { + table.increments('id').primary() + table.string('identity_key').notNullable() + table.timestamp('updated_at').notNullable() + }) + }) + + afterEach(async () => { + await database.destroy() + }) + + it('adds and reverses the indexes used by bounded list endpoints', async () => { + await addListQueryIndexes(database) + + await expect(indexNames(database, 'messages')).resolves.toContain( + 'messages_recipient_box_page_index' + ) + await expect(indexNames(database, 'device_registrations')).resolves.toContain( + 'device_registrations_identity_updated_index' + ) + + await removeListQueryIndexes(database) + + await expect(indexNames(database, 'messages')).resolves.not.toContain( + 'messages_recipient_box_page_index' + ) + await expect(indexNames(database, 'device_registrations')).resolves.not.toContain( + 'device_registrations_identity_updated_index' + ) + }) +}) diff --git a/infra/message-box-server/src/migrations/__tests/messagePermissionScope.test.ts b/infra/message-box-server/src/migrations/__tests/messagePermissionScope.test.ts new file mode 100644 index 000000000..c7817cb3a --- /dev/null +++ b/infra/message-box-server/src/migrations/__tests/messagePermissionScope.test.ts @@ -0,0 +1,64 @@ +import knexFactory, { type Knex } from 'knex' +import { up as createPermissionTables } from '../2025-01-31-001-notification-permissions.js' +import { + down as removeNormalizedScope, + up as addNormalizedScope +} from '../2026-07-26-001-message-permission-scope.js' + +describe('message permission scope migration', () => { + let database: Knex + + beforeEach(async () => { + database = knexFactory({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true + }) + await createPermissionTables(database) + }) + + afterEach(async () => { + await database.destroy() + }) + + it('deduplicates NULL senders and enforces one normalized box-wide scope', async () => { + await database('message_permissions').insert([ + { + recipient: 'recipient', + sender: null, + message_box: 'inbox', + recipient_fee: 0, + updated_at: new Date('2026-01-01') + }, + { + recipient: 'recipient', + sender: null, + message_box: 'inbox', + recipient_fee: 10, + updated_at: new Date('2026-02-01') + } + ]) + + await addNormalizedScope(database) + + await expect( + database('message_permissions').insert({ + recipient: 'recipient', + sender: null, + sender_scope: '', + message_box: 'inbox', + recipient_fee: 0 + }) + ).rejects.toThrow() + + const rows = await database('message_permissions').select('recipient_fee', 'sender_scope') + expect(rows).toEqual([{ recipient_fee: 10, sender_scope: '' }]) + }) + + it('is reversible', async () => { + await addNormalizedScope(database) + await removeNormalizedScope(database) + + expect(await database.schema.hasColumn('message_permissions', 'sender_scope')).toBe(false) + }) +}) diff --git a/infra/message-box-server/src/routes/__tests/acknowledgeMessage.test.ts b/infra/message-box-server/src/routes/__tests/acknowledgeMessage.test.ts index 15ea444d1..8ebb79a3f 100644 --- a/infra/message-box-server/src/routes/__tests/acknowledgeMessage.test.ts +++ b/infra/message-box-server/src/routes/__tests/acknowledgeMessage.test.ts @@ -1,13 +1,14 @@ /* eslint-env jest */ import acknowledgeMessage, { AcknowledgeRequest } from '../acknowledgeMessage.js' +import { MAX_ACKNOWLEDGMENT_IDS, MAX_MESSAGE_ID_BYTES } from '../acknowledgeMessage.js' import mockKnex, { Tracker } from 'mock-knex' import { Response } from 'express' import knexLib from 'knex' import knexConfig from '../../../knexfile.js' import { bindMessageBoxRuntime } from '../../runtimeDeps.js' -const testKnex = (knexLib as any).default?.(knexConfig.development) ?? - (knexLib as any)(knexConfig.development) +const testKnex = + (knexLib as any).default?.(knexConfig.development) ?? (knexLib as any)(knexConfig.development) bindMessageBoxRuntime({ knex: testKnex }) const knex = acknowledgeMessage.knex let queryTracker: Tracker @@ -38,11 +39,11 @@ let validReq: AcknowledgeRequest describe('acknowledgeMessage', () => { beforeAll(() => { - (mockKnex as any).mock(knex) + ;(mockKnex as any).mock(knex) }) beforeEach(() => { - jest.spyOn(console, 'error').mockImplementation(() => { }) + jest.spyOn(console, 'error').mockImplementation(() => {}) queryTracker = (mockKnex as any).getTracker() as Tracker queryTracker.install() @@ -68,7 +69,7 @@ describe('acknowledgeMessage', () => { }) afterAll(async () => { - (mockKnex as any).unmock(knex) + ;(mockKnex as any).unmock(knex) await testKnex.destroy() }) @@ -76,10 +77,12 @@ describe('acknowledgeMessage', () => { delete validReq.body.messageIds await acknowledgeMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'error', - code: 'ERR_MESSAGE_ID_REQUIRED' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + code: 'ERR_MESSAGE_ID_REQUIRED' + }) + ) }) it('Throws an error if messageIds is not an Array', async () => { @@ -88,24 +91,50 @@ describe('acknowledgeMessage', () => { await acknowledgeMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'error', - code: 'ERR_INVALID_MESSAGE_ID', - description: 'Message IDs must be formatted as an array of strings!' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + code: 'ERR_INVALID_MESSAGE_ID' + }) + ) }, 7000) + it('requires an authenticated identity', async () => { + validReq.auth = undefined + + await acknowledgeMessage.func(validReq, mockRes as Response) + + expect(mockRes.status).toHaveBeenCalledWith(401) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ERR_AUTHENTICATION_REQUIRED' + }) + ) + }) + + it.each([ + Array(MAX_ACKNOWLEDGMENT_IDS + 1).fill('id'), + [''], + ['x'.repeat(MAX_MESSAGE_ID_BYTES + 1)] + ])('rejects resource-exhausting or malformed ID collections', async messageIds => { + validReq.body.messageIds = messageIds + + await acknowledgeMessage.func(validReq, mockRes as Response) + + expect(mockRes.status).toHaveBeenCalledWith(400) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ERR_INVALID_MESSAGE_ID' + }) + ) + }) + it('Deletes a message', async () => { queryTracker.on('query', (q, s) => { if (s === 1) { expect(q.method).toEqual('del') - expect(q.sql).toEqual( - 'delete from `messages` where `recipient` = ? and `messageId` in (?)' - ) - expect(q.bindings).toEqual([ - 'mockIdKey', - '123' - ]) + expect(q.sql).toEqual('delete from `messages` where `recipient` = ? and `messageId` in (?)') + expect(q.bindings).toEqual(['mockIdKey', '123']) q.response(true) } else { q.response([]) @@ -114,9 +143,11 @@ describe('acknowledgeMessage', () => { await acknowledgeMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'success' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'success' + }) + ) }) it('Throws an error if deletion fails', async () => { @@ -135,11 +166,13 @@ describe('acknowledgeMessage', () => { await acknowledgeMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'error', - code: 'ERR_INVALID_ACKNOWLEDGMENT', - description: 'Message not found!' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + code: 'ERR_INVALID_ACKNOWLEDGMENT', + description: 'Message not found!' + }) + ) }, 7000) it('Throws unknown errors', async () => { @@ -150,10 +183,12 @@ describe('acknowledgeMessage', () => { await acknowledgeMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(500) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'error', - code: 'ERR_INTERNAL_ERROR', - description: 'An internal error has occurred while acknowledging the message' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + code: 'ERR_INTERNAL_ERROR', + description: 'An internal error has occurred while acknowledging the message' + }) + ) }) }) diff --git a/infra/message-box-server/src/routes/__tests/deviceRoutes.test.ts b/infra/message-box-server/src/routes/__tests/deviceRoutes.test.ts new file mode 100644 index 000000000..29be8866d --- /dev/null +++ b/infra/message-box-server/src/routes/__tests/deviceRoutes.test.ts @@ -0,0 +1,182 @@ +import type { Response } from 'express' +import registerDevice, { + MAX_DEVICE_ID_LENGTH, + MAX_FCM_TOKEN_LENGTH, + type RegisterDeviceRequest +} from '../registerDevice.js' +import listDevices, { MAX_DEVICE_OFFSET, MAX_DEVICE_PAGE_SIZE } from '../listDevices.js' +import { runtimeDeps } from '../../runtimeDeps.js' + +function response(): jest.Mocked { + return { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis() + } as unknown as jest.Mocked +} + +function registerRequest( + body: RegisterDeviceRequest['body'], + identityKey: string | undefined = 'identity-key' +): RegisterDeviceRequest { + return { + auth: identityKey == null ? undefined : { identityKey }, + body + } as RegisterDeviceRequest +} + +describe('device routes', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it.each([ + [{ fcmToken: 'x'.repeat(MAX_FCM_TOKEN_LENGTH + 1) }, 'ERR_INVALID_FCM_TOKEN'], + [ + { + fcmToken: 'token', + deviceId: 'x'.repeat(MAX_DEVICE_ID_LENGTH + 1) + }, + 'ERR_INVALID_DEVICE_ID' + ], + [{ fcmToken: 'token', platform: 'desktop' }, 'ERR_INVALID_PLATFORM'] + ] as const)('rejects invalid registration input', async (body, code) => { + const res = response() + await registerDevice.func(registerRequest(body as RegisterDeviceRequest['body']), res) + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code })) + }) + + it('does not let an identity take over an existing delivery token', async () => { + const first = jest.fn().mockResolvedValue({ + id: 42, + identity_key: 'different-identity' + }) + runtimeDeps.knex = jest.fn(() => ({ + select: () => ({ + where: () => ({ first }) + }) + })) as unknown as typeof runtimeDeps.knex + const res = response() + + await registerDevice.func(registerRequest({ fcmToken: 'token' }), res) + + expect(res.status).toHaveBeenCalledWith(409) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ERR_DEVICE_TOKEN_ALREADY_REGISTERED' + }) + ) + }) + + it('refreshes a token only for its existing owner', async () => { + const update = jest.fn().mockResolvedValue(1) + runtimeDeps.knex = jest + .fn() + .mockReturnValueOnce({ + select: () => ({ + where: () => ({ + first: jest.fn().mockResolvedValue({ id: 42, identity_key: 'identity-key' }) + }) + }) + }) + .mockReturnValueOnce({ + where: () => ({ update }) + }) as unknown as typeof runtimeDeps.knex + const res = response() + + await registerDevice.func( + registerRequest({ fcmToken: 'token', platform: 'web', deviceId: 'browser' }), + res + ) + + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ device_id: 'browser', platform: 'web', active: true }) + ) + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ status: 'success', deviceId: 42 }) + ) + }) + + it('inserts a previously unregistered token', async () => { + const insert = jest.fn().mockResolvedValue([7]) + runtimeDeps.knex = jest + .fn() + .mockReturnValueOnce({ + select: () => ({ + where: () => ({ first: jest.fn().mockResolvedValue(undefined) }) + }) + }) + .mockReturnValueOnce({ insert }) as unknown as typeof runtimeDeps.knex + const res = response() + + await registerDevice.func(registerRequest({ fcmToken: 'new-token' }), res) + + expect(insert).toHaveBeenCalledWith( + expect.objectContaining({ + identity_key: 'identity-key', + fcm_token: 'new-token', + active: true + }) + ) + expect(res.status).toHaveBeenCalledWith(200) + }) + + it('returns a bounded page with redacted tokens', async () => { + const offset = jest.fn().mockResolvedValue([ + { + id: 1, + deviceId: 'browser', + platform: 'web', + fcmToken: 'secret-device-token', + active: true + } + ]) + runtimeDeps.knex = jest.fn(() => ({ + select: () => ({ + where: () => ({ + orderBy: () => ({ + limit: () => ({ offset }) + }) + }) + }) + })) as unknown as typeof runtimeDeps.knex + const res = response() + + await listDevices.func( + { + auth: { identityKey: 'identity-key' }, + query: { limit: '10', offset: '20' } + } as never, + res + ) + + expect(offset).toHaveBeenCalledWith(20) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'success', + limit: 10, + offset: 20, + devices: [expect.objectContaining({ fcmToken: '...vice-token' })] + }) + ) + }) + + it.each([ + [{ limit: String(MAX_DEVICE_PAGE_SIZE + 1) }, 'ERR_INVALID_LIMIT'], + [{ limit: '1.5' }, 'ERR_INVALID_LIMIT'], + [{ offset: String(MAX_DEVICE_OFFSET + 1) }, 'ERR_INVALID_OFFSET'], + [{ offset: 'not-a-number' }, 'ERR_INVALID_OFFSET'] + ])('bounds device pagination', async (query, code) => { + const res = response() + await listDevices.func( + { + auth: { identityKey: 'identity-key' }, + query + } as never, + res + ) + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code })) + }) +}) diff --git a/infra/message-box-server/src/routes/__tests/getQuote.test.ts b/infra/message-box-server/src/routes/__tests/getQuote.test.ts index fd76a3869..36735a29d 100644 --- a/infra/message-box-server/src/routes/__tests/getQuote.test.ts +++ b/infra/message-box-server/src/routes/__tests/getQuote.test.ts @@ -1,11 +1,16 @@ import { beforeEach, describe, expect, jest, test } from '@jest/globals' import type { Response } from 'express' +import type { Knex } from 'knex' import getQuote, { + QUOTE_CONCURRENCY, MAX_QUOTE_RECIPIENTS, type GetQuoteRequest } from '../permissions/getQuote.js' +import { bindMessageBoxRuntime } from '../../runtimeDeps.js' -function createResponse (): jest.Mocked { +const VALID_RECIPIENT = '028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1' + +function createResponse(): jest.Mocked { return { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() @@ -38,4 +43,74 @@ describe('GET /permissions/quote workload limits', () => { description: `A quote may include at most ${MAX_QUOTE_RECIPIENTS} recipients.` }) }) + + test('bounds database concurrency while preserving ordered quote results', async () => { + let activeRecipientQueries = 0 + let maximumRecipientQueries = 0 + + const knex = ((table: string) => { + const query = { + where: () => query, + select: () => query, + first: async () => { + if (table === 'server_fees') return { delivery_fee: 2 } + activeRecipientQueries += 1 + maximumRecipientQueries = Math.max(maximumRecipientQueries, activeRecipientQueries) + await new Promise(resolve => setImmediate(resolve)) + activeRecipientQueries -= 1 + return { recipient_fee: 3 } + } + } + return query + }) as unknown as Knex + bindMessageBoxRuntime({ knex }) + + const recipients = Array.from({ length: QUOTE_CONCURRENCY + 2 }, () => VALID_RECIPIENT) + const req = { + auth: { identityKey: VALID_RECIPIENT }, + query: { recipient: recipients, messageBox: 'inbox' } + } as unknown as GetQuoteRequest + + await getQuote.func(req, res) + + const payload = res.json.mock.calls.at(-1)?.[0] as { + quotesByRecipient: unknown[] + totals: { deliveryFees: number; recipientFees: number } + } + expect(res.status).toHaveBeenCalledWith(200) + expect(maximumRecipientQueries).toBe(QUOTE_CONCURRENCY) + expect(payload.quotesByRecipient).toHaveLength(recipients.length) + expect(payload.totals).toMatchObject({ + deliveryFees: recipients.length * 2, + recipientFees: recipients.length * 3 + }) + }) + + test('fails closed when permission storage is unavailable', async () => { + const knex = (() => { + const query = { + where: () => query, + select: () => query, + first: async () => { + throw new Error('database unavailable') + } + } + return query + }) as unknown as Knex + bindMessageBoxRuntime({ knex }) + + const req = { + auth: { identityKey: VALID_RECIPIENT }, + query: { recipient: VALID_RECIPIENT, messageBox: 'inbox' } + } as unknown as GetQuoteRequest + + await getQuote.func(req, res) + + expect(res.status).toHaveBeenCalledWith(500) + expect(res.json).toHaveBeenCalledWith({ + status: 'error', + code: 'ERR_INTERNAL', + description: 'An internal error has occurred.' + }) + }) }) diff --git a/infra/message-box-server/src/routes/__tests/health.test.ts b/infra/message-box-server/src/routes/__tests/health.test.ts new file mode 100644 index 000000000..872fd39d2 --- /dev/null +++ b/infra/message-box-server/src/routes/__tests/health.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, jest, test } from '@jest/globals' +import type { Request, Response } from 'express' +import { bindMessageBoxRuntime } from '../../runtimeDeps.js' +import { healthRoute, readinessRoute } from '../health.js' + +function createResponse(): jest.Mocked { + return { + setHeader: jest.fn(), + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis() + } as unknown as jest.Mocked +} + +describe('public health routes', () => { + let response: jest.Mocked + + beforeEach(() => { + response = createResponse() + }) + + test('reports process liveness without exposing dependency details', () => { + healthRoute.func({} as Request, response) + + expect(response.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store') + expect(response.status).toHaveBeenCalledWith(200) + expect(response.json).toHaveBeenCalledWith({ status: 'ok' }) + }) + + test('reports readiness when the database responds', async () => { + const raw = jest.fn<(query: string) => Promise>().mockResolvedValue() + bindMessageBoxRuntime({ knex: { raw } as never }) + + await readinessRoute.func({} as Request, response) + + expect(raw).toHaveBeenCalledWith('select 1') + expect(response.status).toHaveBeenCalledWith(200) + expect(response.json).toHaveBeenCalledWith({ status: 'ready' }) + }) + + test('returns a stable, non-sensitive readiness failure', async () => { + const raw = jest.fn<() => Promise>().mockRejectedValue(new Error('secret database host')) + bindMessageBoxRuntime({ knex: { raw } as never }) + + await readinessRoute.func({} as Request, response) + + expect(response.status).toHaveBeenCalledWith(503) + expect(response.json).toHaveBeenCalledWith({ + status: 'error', + code: 'ERR_NOT_READY', + description: 'Message Box dependencies are not ready.' + }) + }) +}) diff --git a/infra/message-box-server/src/routes/__tests/listMessages.test.ts b/infra/message-box-server/src/routes/__tests/listMessages.test.ts index c81fd88d6..bdb488a4a 100644 --- a/infra/message-box-server/src/routes/__tests/listMessages.test.ts +++ b/infra/message-box-server/src/routes/__tests/listMessages.test.ts @@ -1,5 +1,9 @@ /* eslint-env jest */ -import listMessages from '../listMessages.js' +import listMessages, { + MAX_LIST_MESSAGE_BOX_BYTES, + MAX_LIST_MESSAGES_OFFSET, + MAX_LIST_MESSAGES_PAGE_SIZE +} from '../listMessages.js' import mockKnex, { Tracker } from 'mock-knex' import { Response } from 'express' import { AuthRequest } from '@bsv/auth-express-middleware' @@ -8,8 +12,8 @@ import knexConfig from '../../../knexfile.js' import { bindMessageBoxRuntime } from '../../runtimeDeps.js' // Ensure proper handling of mock-knex -const testKnex = (knexLib as any).default?.(knexConfig.development) ?? - (knexLib as any)(knexConfig.development) +const testKnex = + (knexLib as any).default?.(knexConfig.development) ?? (knexLib as any)(knexConfig.development) bindMessageBoxRuntime({ knex: testKnex }) const knex = listMessages.knex let queryTracker: Tracker @@ -38,46 +42,59 @@ const mockRes: jest.Mocked = { let validReq: AuthRequest // eslint-disable-next-line @typescript-eslint/no-unused-vars -let validRes: { status: string, messages: any[] } +let validRes: { status: string; messages: any[] } let validMessageBoxes: Array<{ messageBoxId: number }> -let validMessages: Array<{ sender: string, messageId: string, body: string, created_at: string, updated_at: string }> -let expectedMessages: Array<{ sender: string, messageId: string, body: string, createdAt: string, updatedAt: string }> +let validMessages: Array<{ + sender: string + messageId: string + body: string + created_at: string + updated_at: string +}> +let expectedMessages: Array<{ + sender: string + messageId: string + body: string + createdAt: string + updatedAt: string +}> describe('listMessages', () => { beforeAll(() => { - (mockKnex as any).mock(knex) + ;(mockKnex as any).mock(knex) }) beforeEach(() => { - jest.spyOn(console, 'error').mockImplementation(() => { }) + jest.spyOn(console, 'error').mockImplementation(() => {}) queryTracker = (mockKnex as any).getTracker() as Tracker queryTracker.install() - validMessages = [{ - sender: '028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1', - messageId: 'msg-1', - body: '{}', - created_at: '2024-01-01', - updated_at: '2024-01-01' - }] - expectedMessages = [{ - sender: '028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1', - messageId: 'msg-1', - body: '{}', - createdAt: '2024-01-01', - updatedAt: '2024-01-01' - }] + validMessages = [ + { + sender: '028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1', + messageId: 'msg-1', + body: '{}', + created_at: '2024-01-01', + updated_at: '2024-01-01' + } + ] + expectedMessages = [ + { + sender: '028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1', + messageId: 'msg-1', + body: '{}', + createdAt: '2024-01-01', + updatedAt: '2024-01-01' + } + ] // Mock Data validRes = { status: 'success', messages: validMessages } - validMessageBoxes = [ - { messageBoxId: 42 }, - { messageBoxId: 31 } - ] + validMessageBoxes = [{ messageBoxId: 42 }, { messageBoxId: 31 }] // Fully typed mock request validReq = { @@ -101,36 +118,80 @@ describe('listMessages', () => { }) afterAll(async () => { - (mockKnex as any).unmock(knex) + ;(mockKnex as any).unmock(knex) await testKnex.destroy() }) it('Throws an error if a messageBox is not provided', async () => { validReq.body.messageBox = undefined - queryTracker.on('query', (q) => { + queryTracker.on('query', q => { q.response([]) }) await listMessages.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'error', - code: 'ERR_MESSAGEBOX_REQUIRED', - description: 'Please provide the name of a valid MessageBox!' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + code: 'ERR_MESSAGEBOX_REQUIRED', + description: 'Please provide the name of a valid MessageBox!' + }) + ) + }) + + it('requires an authenticated identity', async () => { + validReq.auth = undefined + + await listMessages.func(validReq, mockRes as Response) + + expect(mockRes.status).toHaveBeenCalledWith(401) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ERR_AUTHENTICATION_REQUIRED' + }) + ) + }) + + it('rejects oversized messageBox names before querying storage', async () => { + validReq.body.messageBox = 'x'.repeat(MAX_LIST_MESSAGE_BOX_BYTES + 1) + + await listMessages.func(validReq, mockRes as Response) + + expect(mockRes.status).toHaveBeenCalledWith(400) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ERR_INVALID_MESSAGEBOX' + }) + ) + }) + + it.each([ + [{ limit: MAX_LIST_MESSAGES_PAGE_SIZE + 1 }, 'ERR_INVALID_LIMIT'], + [{ limit: 1.5 }, 'ERR_INVALID_LIMIT'], + [{ offset: MAX_LIST_MESSAGES_OFFSET + 1 }, 'ERR_INVALID_OFFSET'], + [{ offset: -1 }, 'ERR_INVALID_OFFSET'] + ])('bounds message listing pagination', async (pagination, code) => { + Object.assign(validReq.body, pagination) + + await listMessages.func(validReq, mockRes as Response) + + expect(mockRes.status).toHaveBeenCalledWith(400) + expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ code })) }) it('Throws an error if messageBox is not a string', async () => { validReq.body.messageBox = 123 as unknown as string - queryTracker.on('query', (q) => { + queryTracker.on('query', q => { q.response([]) }) await listMessages.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'error', - code: 'ERR_INVALID_MESSAGEBOX', - description: 'MessageBox name must be a string!' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + code: 'ERR_INVALID_MESSAGEBOX', + description: 'MessageBox name must be a string!' + }) + ) }) it('Throws an error if no matching messageBox is found', async () => { @@ -148,10 +209,12 @@ describe('listMessages', () => { }) await listMessages.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'success', - messages: [] - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'success', + messages: [] + }) + ) }) it('Returns ID of messageBox', async () => { @@ -161,10 +224,7 @@ describe('listMessages', () => { expect(q.sql).toEqual( 'select `messageBoxId` from `messageBox` where `identityKey` = ? and `type` = ?' ) - expect(q.bindings).toEqual([ - 'mockIdKey', - 'payment_inbox' - ]) + expect(q.bindings).toEqual(['mockIdKey', 'payment_inbox']) q.response([validMessageBoxes[0]]) } else if (s === 2) { q.response(validMessages) @@ -174,10 +234,12 @@ describe('listMessages', () => { }) await listMessages.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'success', - messages: expectedMessages - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'success', + messages: expectedMessages + }) + ) }) it('Returns empty array if no messages found', async () => { @@ -186,8 +248,8 @@ describe('listMessages', () => { q.response([{ messageBoxId: 123 }]) } else if (s === 2) { expect(q.method).toEqual('select') - expect(q.sql).toEqual( - 'select `messageId`, `body`, `sender`, `created_at`, `updated_at` from `messages` where `recipient` = ? and `messageBoxId` = ?' + expect(q.sql).toContain( + 'select `messageId`, `body`, `sender`, `created_at`, `updated_at` from `messages`' ) q.response([]) } else { @@ -196,10 +258,12 @@ describe('listMessages', () => { }) await listMessages.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'success', - messages: [] - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'success', + messages: [] + }) + ) }) it('Returns list of messages found', async () => { @@ -208,8 +272,8 @@ describe('listMessages', () => { q.response([{ messageBoxId: 123 }]) } else if (s === 2) { expect(q.method).toEqual('select') - expect(q.sql).toEqual( - 'select `messageId`, `body`, `sender`, `created_at`, `updated_at` from `messages` where `recipient` = ? and `messageBoxId` = ?' + expect(q.sql).toContain( + 'select `messageId`, `body`, `sender`, `created_at`, `updated_at` from `messages`' ) q.response(validMessages) } else { @@ -218,10 +282,12 @@ describe('listMessages', () => { }) await listMessages.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'success', - messages: expectedMessages - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'success', + messages: expectedMessages + }) + ) }) it('Throws unknown errors', async () => { @@ -232,10 +298,12 @@ describe('listMessages', () => { await listMessages.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(500) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'error', - code: 'ERR_INTERNAL_ERROR', - description: 'An internal error has occurred while listing messages.' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + code: 'ERR_INTERNAL_ERROR', + description: 'An internal error has occurred while listing messages.' + }) + ) }) }) diff --git a/infra/message-box-server/src/routes/__tests/permissionValidation.test.ts b/infra/message-box-server/src/routes/__tests/permissionValidation.test.ts new file mode 100644 index 000000000..fdba20c3e --- /dev/null +++ b/infra/message-box-server/src/routes/__tests/permissionValidation.test.ts @@ -0,0 +1,95 @@ +import type { Response } from 'express' +import setPermission, { + MAX_PERMISSION_MESSAGE_BOX_BYTES, + MAX_RECIPIENT_FEE +} from '../permissions/setPermission.js' +import listPermissions, { + MAX_PERMISSION_OFFSET, + MAX_PERMISSION_PAGE_SIZE +} from '../permissions/listPermissions.js' +import getPermission from '../permissions/getPermission.js' + +function response(): jest.Mocked { + return { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis() + } as unknown as jest.Mocked +} + +describe('permission route validation', () => { + it.each([-2, 1.5, Number.NaN, MAX_RECIPIENT_FEE + 1])( + 'rejects invalid recipient fee %s', + async recipientFee => { + const res = response() + await setPermission.func( + { + auth: { identityKey: 'identity-key' }, + body: { messageBox: 'inbox', recipientFee } + } as never, + res + ) + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ERR_INVALID_FEE_VALUE' + }) + ) + } + ) + + it('bounds the message-box name consistently', async () => { + const res = response() + await setPermission.func( + { + auth: { identityKey: 'identity-key' }, + body: { + messageBox: 'x'.repeat(MAX_PERMISSION_MESSAGE_BOX_BYTES + 1), + recipientFee: 0 + } + } as never, + res + ) + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ERR_INVALID_MESSAGE_BOX' + }) + ) + }) + + it.each([ + [{ limit: String(MAX_PERMISSION_PAGE_SIZE + 1) }, 'ERR_INVALID_LIMIT'], + [{ limit: '10x' }, 'ERR_INVALID_LIMIT'], + [{ offset: String(MAX_PERMISSION_OFFSET + 1) }, 'ERR_INVALID_OFFSET'], + [{ createdAtOrder: 'sideways' }, 'ERR_INVALID_SORT_ORDER'], + [{ messageBox: '' }, 'ERR_INVALID_MESSAGE_BOX'] + ])('strictly validates permission-list pagination and filters', async (query, code) => { + const res = response() + await listPermissions.func( + { + auth: { identityKey: 'identity-key' }, + query + } as never, + res + ) + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code })) + }) + + it('requires a bounded non-empty messageBox for permission lookup', async () => { + const res = response() + await getPermission.func( + { + auth: { identityKey: 'identity-key' }, + query: { messageBox: ' ' } + } as never, + res + ) + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ERR_INVALID_MESSAGE_BOX' + }) + ) + }) +}) diff --git a/infra/message-box-server/src/routes/__tests/sendMessage.test.ts b/infra/message-box-server/src/routes/__tests/sendMessage.test.ts index 609f134f8..a20cb6a07 100644 --- a/infra/message-box-server/src/routes/__tests/sendMessage.test.ts +++ b/infra/message-box-server/src/routes/__tests/sendMessage.test.ts @@ -21,8 +21,8 @@ import AxiosMockAdapter from 'axios-mock-adapter' global.fetch = jest.fn() -const testKnex = (knexLib as any).default?.(knexConfig.development) ?? - (knexLib as any)(knexConfig.development) +const testKnex = + (knexLib as any).default?.(knexConfig.development) ?? (knexLib as any)(knexConfig.development) bindMessageBoxRuntime({ knex: testKnex }) const knex = sendMessage.knex let queryTracker: Tracker @@ -53,7 +53,7 @@ const mockRes: jest.Mocked = { let validReq: SendMessageRequest // eslint-disable-next-line @typescript-eslint/no-unused-vars let validRes: { status: string } -let validMessageBox: { messageBoxId: number, type: string } +let validMessageBox: { messageBoxId: number; type: string } describe('sendMessage', () => { // Capture original console methods @@ -127,11 +127,13 @@ describe('sendMessage', () => { await sendMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'error', - code: 'ERR_MESSAGE_REQUIRED', - description: 'Please provide a valid message to send!' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + code: 'ERR_MESSAGE_REQUIRED', + description: 'Please provide a valid message to send!' + }) + ) }) it('Throws an error if message is not an object', async () => { @@ -139,11 +141,13 @@ describe('sendMessage', () => { await sendMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'error', - code: 'ERR_INVALID_MESSAGEBOX', - description: 'Invalid message box.' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + code: 'ERR_INVALID_MESSAGEBOX', + description: 'Invalid message box.' + }) + ) }) it('Throws an error if recipient is missing', async () => { @@ -153,11 +157,13 @@ describe('sendMessage', () => { await sendMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'error', - code: 'ERR_RECIPIENT_REQUIRED', - description: 'Missing recipient(s). Provide "recipient" or "recipients".' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + code: 'ERR_RECIPIENT_REQUIRED', + description: 'Missing recipient(s). Provide "recipient" or "recipients".' + }) + ) }) it('Throws an error if recipient is not a string', async () => { @@ -167,10 +173,12 @@ describe('sendMessage', () => { await sendMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'error', - code: 'ERR_INVALID_RECIPIENT_KEY' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + code: 'ERR_INVALID_RECIPIENT_KEY' + }) + ) }) it('Returns error if messageBox is missing', async () => { @@ -180,10 +188,12 @@ describe('sendMessage', () => { await sendMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'error', - code: 'ERR_INVALID_MESSAGEBOX' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + code: 'ERR_INVALID_MESSAGEBOX' + }) + ) }) it('Throws an error if messageBox is not a string', async () => { @@ -193,11 +203,13 @@ describe('sendMessage', () => { await sendMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'error', - code: 'ERR_INVALID_MESSAGEBOX', - description: 'Invalid message box.' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + code: 'ERR_INVALID_MESSAGEBOX', + description: 'Invalid message box.' + }) + ) }) it('Throws an error if the message body is not a string', async () => { @@ -207,11 +219,13 @@ describe('sendMessage', () => { await sendMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'error', - code: 'ERR_INVALID_MESSAGE_BODY', - description: 'Invalid message body.' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + code: 'ERR_INVALID_MESSAGE_BODY', + description: 'Invalid message body.' + }) + ) }) it('Returns error if message body is missing', async () => { @@ -221,11 +235,13 @@ describe('sendMessage', () => { await sendMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'error', - code: 'ERR_INVALID_MESSAGE_BODY', - description: 'Invalid message body.' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + code: 'ERR_INVALID_MESSAGE_BODY', + description: 'Invalid message body.' + }) + ) }) it('rejects recipient fan-out above the service limit', async () => { @@ -235,9 +251,11 @@ describe('sendMessage', () => { await sendMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - code: 'ERR_TOO_MANY_RECIPIENTS' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ERR_TOO_MANY_RECIPIENTS' + }) + ) }) it('rejects message-box names above the byte limit', async () => { @@ -246,9 +264,11 @@ describe('sendMessage', () => { await sendMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - code: 'ERR_MESSAGEBOX_TOO_LARGE' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ERR_MESSAGEBOX_TOO_LARGE' + }) + ) }) it('rejects message IDs above the byte limit', async () => { @@ -257,9 +277,11 @@ describe('sendMessage', () => { await sendMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - code: 'ERR_INVALID_MESSAGEID' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ERR_INVALID_MESSAGEID' + }) + ) }) it('rejects message bodies above the byte limit before database work', async () => { @@ -268,9 +290,11 @@ describe('sendMessage', () => { await sendMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(413) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - code: 'ERR_MESSAGE_BODY_TOO_LARGE' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ERR_MESSAGE_BODY_TOO_LARGE' + }) + ) }) it('Returns base price for an empty message', () => { @@ -326,11 +350,13 @@ describe('sendMessage', () => { await sendMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'error', - code: 'ERR_MESSAGEID_REQUIRED', - description: 'Missing messageId.' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + code: 'ERR_MESSAGEID_REQUIRED', + description: 'Missing messageId.' + }) + ) }) it('Creates a messageBox when it does not exist', async () => { @@ -349,9 +375,11 @@ describe('sendMessage', () => { await sendMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'success' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'success' + }) + ) }) it('Silently ignores duplicate messages via onConflict().ignore()', async () => { @@ -370,9 +398,11 @@ describe('sendMessage', () => { await sendMessage.func(validReq, mockRes as Response) expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'success' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'success' + }) + ) }) it('Returns internal error if unexpected error occurs', async () => { @@ -390,27 +420,34 @@ describe('sendMessage', () => { // Ensure the response body is set expect(mockRes.json).toHaveBeenCalledTimes(1) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'error', - code: 'ERR_INTERNAL' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + code: 'ERR_INTERNAL' + }) + ) }) - it('creates a new messageBox when one does not exist for recipient', async () => { queryTracker.on('query', (q, step) => { - if (step === 1) q.response(undefined) // messageBox not found - else if (step === 2) q.response(1) // messageBox insert - else if (step === 3) q.response({ messageBoxId: 42 }) // get messageBoxId for message insert - else if (step === 4) q.response(1) // insert message + if (step === 1) + q.response(undefined) // messageBox not found + else if (step === 2) + q.response(1) // messageBox insert + else if (step === 3) + q.response({ messageBoxId: 42 }) // get messageBoxId for message insert + else if (step === 4) + q.response(1) // insert message else q.response([]) }) await sendMessage.func(validReq, mockRes) expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ - status: 'success' - })) + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'success' + }) + ) }) }) diff --git a/infra/message-box-server/src/routes/acknowledgeMessage.ts b/infra/message-box-server/src/routes/acknowledgeMessage.ts index 89b66fde7..695f85d7f 100644 --- a/infra/message-box-server/src/routes/acknowledgeMessage.ts +++ b/infra/message-box-server/src/routes/acknowledgeMessage.ts @@ -9,17 +9,20 @@ * and handled on the client side (e.g., after syncing or displaying them). */ -import { Request, Response } from 'express' +import { Response } from 'express' +import { AuthRequest } from '@bsv/auth-express-middleware' import { Logger } from '../utils/logger.js' import { runtimeDeps } from '../runtimeDeps.js' +export const MAX_ACKNOWLEDGMENT_IDS = 1_000 +export const MAX_MESSAGE_ID_BYTES = 256 + /** * @interface AcknowledgeRequest * @extends Request * @description Represents an authenticated request body for acknowledging messages. */ -export interface AcknowledgeRequest extends Request { - auth: { identityKey: string } +export interface AcknowledgeRequest extends AuthRequest { body: { messageIds?: string[] } } @@ -70,7 +73,9 @@ export interface AcknowledgeRequest extends Request { export default { type: 'post', path: '/acknowledgeMessage', - get knex () { return runtimeDeps.knex }, + get knex() { + return runtimeDeps.knex + }, summary: 'Use this route to acknowledge a message has been received', parameters: { messageIds: ['3301'] @@ -100,11 +105,24 @@ export default { func: async (req: AcknowledgeRequest, res: Response): Promise => { try { const { messageIds } = req.body + const identityKey = req.auth?.identityKey - Logger.log('[SERVER] acknowledgeMessage called for messageIds:', messageIds, 'by', req.auth.identityKey) + if (identityKey == null || identityKey.trim() === '') { + return res.status(401).json({ + status: 'error', + code: 'ERR_AUTHENTICATION_REQUIRED', + description: 'Authentication required.' + }) + } + + Logger.log( + '[SERVER] acknowledgeMessage called for', + Array.isArray(messageIds) ? messageIds.length : 0, + 'message(s)' + ) // Validate request: must be a non-empty array of strings - if ((messageIds == null) || (Array.isArray(messageIds) && messageIds.length === 0)) { + if (messageIds == null || (Array.isArray(messageIds) && messageIds.length === 0)) { return res.status(400).json({ status: 'error', code: 'ERR_MESSAGE_ID_REQUIRED', @@ -112,18 +130,32 @@ export default { }) } - if (!Array.isArray(messageIds) || messageIds.some(id => typeof id !== 'string')) { + if ( + !Array.isArray(messageIds) || + messageIds.length > MAX_ACKNOWLEDGMENT_IDS || + messageIds.some( + id => + typeof id !== 'string' || + id.trim() === '' || + Buffer.byteLength(id, 'utf8') > MAX_MESSAGE_ID_BYTES + ) + ) { return res.status(400).json({ status: 'error', code: 'ERR_INVALID_MESSAGE_ID', - description: 'Message IDs must be formatted as an array of strings!' + description: + `Message IDs must be a non-empty array of at most ${MAX_ACKNOWLEDGMENT_IDS} ` + + `non-empty strings no longer than ${MAX_MESSAGE_ID_BYTES} bytes each.` }) } + const uniqueMessageIds = [...new Set(messageIds)] + // Delete acknowledged messages for this recipient from the database - const deleted = await runtimeDeps.knex('messages') - .where({ recipient: req.auth.identityKey }) - .whereIn('messageId', Array.isArray(messageIds) ? messageIds : [messageIds]) + const deleted = await runtimeDeps + .knex('messages') + .where({ recipient: identityKey }) + .whereIn('messageId', uniqueMessageIds) .del() // No matching messages found diff --git a/infra/message-box-server/src/routes/health.ts b/infra/message-box-server/src/routes/health.ts new file mode 100644 index 000000000..9a51096c3 --- /dev/null +++ b/infra/message-box-server/src/routes/health.ts @@ -0,0 +1,32 @@ +import type { Request, Response } from 'express' +import { runtimeDeps } from '../runtimeDeps.js' + +const NO_STORE = 'no-store' + +export const healthRoute = { + type: 'get', + path: '/health', + func: (_req: Request, res: Response): Response => { + res.setHeader('Cache-Control', NO_STORE) + return res.status(200).json({ status: 'ok' }) + } +} + +export const readinessRoute = { + type: 'get', + path: '/ready', + func: async (_req: Request, res: Response): Promise => { + res.setHeader('Cache-Control', NO_STORE) + try { + if (runtimeDeps.knex == null) throw new Error('Database is not configured') + await runtimeDeps.knex.raw('select 1') + return res.status(200).json({ status: 'ready' }) + } catch { + return res.status(503).json({ + status: 'error', + code: 'ERR_NOT_READY', + description: 'Message Box dependencies are not ready.' + }) + } + } +} diff --git a/infra/message-box-server/src/routes/index.ts b/infra/message-box-server/src/routes/index.ts index ff94db2cb..d71be7bca 100644 --- a/infra/message-box-server/src/routes/index.ts +++ b/infra/message-box-server/src/routes/index.ts @@ -4,10 +4,14 @@ import acknowledgeMessage from './acknowledgeMessage.js' import registerDevice from './registerDevice.js' import listDevices from './listDevices.js' import { permissionRoutes } from './permissions/index.js' +import { healthRoute, readinessRoute } from './health.js' // Explicitly type the exported arrays to avoid type inference issues -export const preAuth: Array<{ type: string, path: string, func: Function }> = [] -export const postAuth: Array<{ type: string, path: string, func: Function }> = [ +export const preAuth: Array<{ type: string; path: string; func: Function }> = [ + healthRoute, + readinessRoute +] +export const postAuth: Array<{ type: string; path: string; func: Function }> = [ sendMessage, listMessages, acknowledgeMessage, diff --git a/infra/message-box-server/src/routes/listDevices.ts b/infra/message-box-server/src/routes/listDevices.ts index 75b95c0f1..8154a2d50 100644 --- a/infra/message-box-server/src/routes/listDevices.ts +++ b/infra/message-box-server/src/routes/listDevices.ts @@ -3,6 +3,16 @@ import { Logger } from '../utils/logger.js' import { AuthRequest } from '@bsv/auth-express-middleware' import { runtimeDeps } from '../runtimeDeps.js' +export const MAX_DEVICE_PAGE_SIZE = 100 +export const MAX_DEVICE_OFFSET = 100_000 + +interface ListDevicesRequest extends AuthRequest { + query: { + limit?: string + offset?: string + } +} + export interface RegisteredDevice { id: number deviceId: string | null @@ -17,7 +27,7 @@ export interface RegisteredDevice { export default { type: 'get', path: '/devices', - func: async (req: AuthRequest, res: Response): Promise => { + func: async (req: ListDevicesRequest, res: Response): Promise => { try { Logger.log('[DEBUG] Processing list devices request') @@ -32,9 +42,29 @@ export default { }) } + const limitValue = req.query?.limit + const offsetValue = req.query?.offset + const limit = limitValue == null ? MAX_DEVICE_PAGE_SIZE : Number(limitValue) + const offset = offsetValue == null ? 0 : Number(offsetValue) + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_DEVICE_PAGE_SIZE) { + return res.status(400).json({ + status: 'error', + code: 'ERR_INVALID_LIMIT', + description: `limit must be an integer between 1 and ${MAX_DEVICE_PAGE_SIZE}.` + }) + } + if (!Number.isSafeInteger(offset) || offset < 0 || offset > MAX_DEVICE_OFFSET) { + return res.status(400).json({ + status: 'error', + code: 'ERR_INVALID_OFFSET', + description: `offset must be an integer between 0 and ${MAX_DEVICE_OFFSET}.` + }) + } + try { // Query devices for the authenticated user - const devices = await runtimeDeps.knex('device_registrations') + const devices = await runtimeDeps + .knex('device_registrations') .select([ 'id', 'device_id as deviceId', @@ -47,18 +77,21 @@ export default { ]) .where('identity_key', identityKey) .orderBy('updated_at', 'desc') + .limit(limit) + .offset(offset) Logger.log(`[DEBUG] Found ${devices.length} registered devices for ${identityKey}`) return res.status(200).json({ status: 'success', + limit, + offset, devices: devices.map(device => ({ ...device, // Truncate FCM token for security (show only last 10 characters) fcmToken: `...${device.fcmToken.slice(-10)}` })) }) - } catch (dbError: any) { Logger.error('[ERROR] Database error during device listing:', dbError) return res.status(500).json({ @@ -67,7 +100,6 @@ export default { description: 'Failed to retrieve devices.' }) } - } catch (error) { Logger.error('[ERROR] Internal Server Error in listDevices:', error) return res.status(500).json({ diff --git a/infra/message-box-server/src/routes/listMessages.ts b/infra/message-box-server/src/routes/listMessages.ts index 6fe677acc..a8f7f591f 100644 --- a/infra/message-box-server/src/routes/listMessages.ts +++ b/infra/message-box-server/src/routes/listMessages.ts @@ -14,13 +14,21 @@ import { AuthRequest } from '@bsv/auth-express-middleware' import { log } from '../utils/logger.js' import { runtimeDeps } from '../runtimeDeps.js' +export const MAX_LIST_MESSAGE_BOX_BYTES = 128 +export const MAX_LIST_MESSAGES_PAGE_SIZE = 1_000 +export const MAX_LIST_MESSAGES_OFFSET = 100_000 + /** * @interface ListMessagesRequest * @extends Request * @description Extends Express Request to include `auth` identity and expected `messageBox` body property. */ interface ListMessagesRequest extends AuthRequest { - body: { messageBox?: string } + body: { + messageBox?: string + limit?: number + offset?: number + } } /** @@ -29,7 +37,8 @@ interface ListMessagesRequest extends AuthRequest { * post: * summary: Retrieve messages from a specific messageBox * description: | - * Returns all stored messages for the specified messageBox that belong to the authenticated identity. + * Returns one deterministic, bounded page of stored messages for the specified messageBox + * that belong to the authenticated identity. * If the box does not exist or has no messages, an empty array is returned. * tags: * - Message @@ -85,7 +94,9 @@ interface ListMessagesRequest extends AuthRequest { export default { type: 'post', path: '/listMessages', - get knex () { return runtimeDeps.knex }, + get knex() { + return runtimeDeps.knex + }, summary: 'Use this route to list messages from your messageBox.', parameters: { messageBox: 'The name of the messageBox you would like to list messages from.' @@ -101,35 +112,44 @@ export default { ] }, /** - * @function func - * @description - * Express handler for listing stored messages in a specified messageBox. - * - * Input: - * - `req.body.messageBox`: Name of the messageBox to retrieve messages from. - * - `req.auth.identityKey`: Authenticated user’s public identity key. - * - * Behavior: - * - Checks if the specified messageBox exists for the identity. - * - If found, returns all messages in that messageBox. - * - If not found, returns an empty array. - * - Normalizes all message bodies to strings for consistent output. - * - * Output: - * - 200 with `{ status: 'success', messages: [...] }` - * - 400 if input is missing or malformed. - * - 500 on internal server/database errors. - * - * @param {ListMessagesRequest} req - Authenticated request containing the messageBox name - * @param {Response} res - Express response object - * @returns {Promise} JSON response containing message records or an error - */ + * @function func + * @description + * Express handler for listing stored messages in a specified messageBox. + * + * Input: + * - `req.body.messageBox`: Name of the messageBox to retrieve messages from. + * - `req.auth.identityKey`: Authenticated user’s public identity key. + * + * Behavior: + * - Checks if the specified messageBox exists for the identity. + * - If found, returns one deterministic page from that messageBox. + * - If not found, returns an empty array. + * - Normalizes all message bodies to strings for consistent output. + * + * Output: + * - 200 with `{ status: 'success', messages: [...] }` + * - 400 if input is missing or malformed. + * - 500 on internal server/database errors. + * + * @param {ListMessagesRequest} req - Authenticated request containing the messageBox name + * @param {Response} res - Express response object + * @returns {Promise} JSON response containing message records or an error + */ func: async (req: ListMessagesRequest, res: Response): Promise => { try { const { messageBox } = req.body + const identityKey = req.auth?.identityKey + + if (identityKey == null || identityKey.trim() === '') { + return res.status(401).json({ + status: 'error', + code: 'ERR_AUTHENTICATION_REQUIRED', + description: 'Authentication required.' + }) + } // Validate a messageBox is provided and is a string - if (messageBox == null || messageBox === '') { + if (messageBox == null || (typeof messageBox === 'string' && messageBox.trim() === '')) { return res.status(400).json({ status: 'error', code: 'ERR_MESSAGEBOX_REQUIRED', @@ -145,11 +165,38 @@ export default { }) } + const normalizedMessageBox = messageBox.trim() + if (Buffer.byteLength(normalizedMessageBox, 'utf8') > MAX_LIST_MESSAGE_BOX_BYTES) { + return res.status(400).json({ + status: 'error', + code: 'ERR_INVALID_MESSAGEBOX', + description: `MessageBox names must not exceed ${MAX_LIST_MESSAGE_BOX_BYTES} bytes.` + }) + } + + const limit = req.body.limit ?? MAX_LIST_MESSAGES_PAGE_SIZE + const offset = req.body.offset ?? 0 + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIST_MESSAGES_PAGE_SIZE) { + return res.status(400).json({ + status: 'error', + code: 'ERR_INVALID_LIMIT', + description: `limit must be an integer between 1 and ${MAX_LIST_MESSAGES_PAGE_SIZE}.` + }) + } + if (!Number.isSafeInteger(offset) || offset < 0 || offset > MAX_LIST_MESSAGES_OFFSET) { + return res.status(400).json({ + status: 'error', + code: 'ERR_INVALID_OFFSET', + description: `offset must be an integer between 0 and ${MAX_LIST_MESSAGES_OFFSET}.` + }) + } + // Find the messageBox ID for this user - const [messageBoxRecord] = await runtimeDeps.knex('messageBox') + const [messageBoxRecord] = await runtimeDeps + .knex('messageBox') .where({ - identityKey: req.auth?.identityKey, - type: messageBox + identityKey, + type: normalizedMessageBox }) .select('messageBoxId') @@ -157,17 +204,28 @@ export default { if (messageBoxRecord === undefined) { return res.status(200).json({ status: 'success', - messages: [] + messages: [], + limit, + offset, + hasMore: false }) } - // Retrieve all messages associated with the messageBox - const messages = await runtimeDeps.knex('messages') + // Retrieve one bounded, deterministic page. + const messageRows = await runtimeDeps + .knex('messages') .where({ - recipient: req.auth?.identityKey, + recipient: identityKey, messageBoxId: messageBoxRecord.messageBoxId }) .select('messageId', 'body', 'sender', 'created_at', 'updated_at') + .orderBy('created_at', 'asc') + .orderBy('messageId', 'asc') + .limit(limit + 1) + .offset(offset) + + const hasMore = messageRows.length > limit + const messages = messageRows.slice(0, limit) // Normalize all message bodies to strings and convert to camelCase const formattedMessages = messages.map(message => ({ @@ -181,7 +239,10 @@ export default { // Return a list of matching messages return res.status(200).json({ status: 'success', - messages: formattedMessages + messages: formattedMessages, + limit, + offset, + hasMore }) } catch (e) { log.error({ operation: 'messages.list', outcome: 'error', err: e }, 'Failed to list messages') diff --git a/infra/message-box-server/src/routes/permissions/getPermission.ts b/infra/message-box-server/src/routes/permissions/getPermission.ts index c6b34f31b..9d2d6d9db 100644 --- a/infra/message-box-server/src/routes/permissions/getPermission.ts +++ b/infra/message-box-server/src/routes/permissions/getPermission.ts @@ -4,6 +4,8 @@ import { Logger } from '../../utils/logger.js' import { AuthRequest } from '@bsv/auth-express-middleware' import { runtimeDeps } from '../../runtimeDeps.js' +const MAX_MESSAGE_BOX_BYTES = 128 + export interface GetPermissionRequest extends AuthRequest { query: { sender?: string // identityKey of sender to check @@ -34,7 +36,7 @@ export interface GetPermissionRequest extends AuthRequest { * description: messageBox type to check * responses: * 200: - * description: Permission setting retrieved successfully (or undefined if not set) + * description: Permission setting retrieved successfully (or null if not set) * 400: * description: Invalid request parameters * 401: @@ -62,12 +64,16 @@ export default { const { sender, messageBox } = req.query // Validate required parameters - if (messageBox == null) { + if ( + typeof messageBox !== 'string' || + messageBox.trim() === '' || + Buffer.byteLength(messageBox.trim(), 'utf8') > MAX_MESSAGE_BOX_BYTES + ) { Logger.log('[DEBUG] Missing required parameters for get permission') return res.status(400).json({ status: 'error', - code: 'ERR_MISSING_PARAMETERS', - description: 'messageBox parameter is required.' + code: 'ERR_INVALID_MESSAGE_BOX', + description: `messageBox must be a non-empty string of at most ${MAX_MESSAGE_BOX_BYTES} bytes.` }) } @@ -86,26 +92,25 @@ export default { } const recipient = req.auth.identityKey + const normalizedMessageBox = messageBox.trim() // Get message permission directly from database - const whereClause: any = { + const whereClause = { recipient, - message_box: messageBox - } - - // Add sender condition (null for box-wide, specific sender for sender-specific) - if (sender != null) { - whereClause.sender = sender - } else { - whereClause.sender = null + message_box: normalizedMessageBox, + sender_scope: sender ?? '' } - const permission = await runtimeDeps.knex('message_permissions') + const permission = await runtimeDeps + .knex('message_permissions') .where(whereClause) .select('recipient_fee', 'created_at', 'updated_at') .first() - Logger.log(`[DEBUG] Permission record for ${sender ?? 'box-wide'} -> ${recipient} (${messageBox}): ${JSON.stringify(permission)}`) + Logger.log( + `[DEBUG] Permission record for ${sender ?? 'box-wide'} -> authenticated recipient ` + + `(${normalizedMessageBox}): ${permission == null ? 'not found' : 'found'}` + ) if (permission != null) { // Helper function to determine status from recipient fee @@ -118,12 +123,13 @@ export default { // Permission is set, return it return res.status(200).json({ status: 'success', - description: sender != null - ? `Permission setting found for sender ${sender} to ${messageBox}.` - : `Box-wide permission setting found for ${messageBox}.`, + description: + sender != null + ? `Permission setting found for sender ${sender} to ${normalizedMessageBox}.` + : `Box-wide permission setting found for ${normalizedMessageBox}.`, permission: { sender: sender ?? null, - messageBox, + messageBox: normalizedMessageBox, recipientFee: permission.recipient_fee, status: getStatusFromFee(permission.recipient_fee), createdAt: permission.created_at.toISOString(), @@ -134,10 +140,11 @@ export default { // No permission set, return undefined return res.status(200).json({ status: 'success', - description: sender != null - ? `No permission setting found for sender ${sender} to ${messageBox}.` - : `No box-wide permission setting found for ${messageBox}.`, - permission: undefined + description: + sender != null + ? `No permission setting found for sender ${sender} to ${normalizedMessageBox}.` + : `No box-wide permission setting found for ${normalizedMessageBox}.`, + permission: null }) } } catch (error) { diff --git a/infra/message-box-server/src/routes/permissions/getQuote.ts b/infra/message-box-server/src/routes/permissions/getQuote.ts index 06a8ad56a..32af211ec 100644 --- a/infra/message-box-server/src/routes/permissions/getQuote.ts +++ b/infra/message-box-server/src/routes/permissions/getQuote.ts @@ -5,11 +5,13 @@ import { AuthRequest } from '@bsv/auth-express-middleware' import { getRecipientFee, getServerDeliveryFee } from '../../utils/messagePermissions.js' export const MAX_QUOTE_RECIPIENTS = 100 +export const QUOTE_CONCURRENCY = 10 +const MAX_MESSAGE_BOX_BYTES = 128 export interface GetQuoteRequest extends AuthRequest { query: { recipient: string | string[] // identityKey of recipient or array of recipients - messageBox?: string // messageBox type + messageBox?: string // messageBox type } } @@ -79,6 +81,17 @@ export default { description: 'recipient and messageBox parameters are required.' }) } + if ( + typeof messageBox !== 'string' || + messageBox.trim() === '' || + Buffer.byteLength(messageBox, 'utf8') > MAX_MESSAGE_BOX_BYTES + ) { + return res.status(400).json({ + status: 'error', + code: 'ERR_INVALID_MESSAGE_BOX', + description: `messageBox must be a non-empty string of at most ${MAX_MESSAGE_BOX_BYTES} bytes.` + }) + } // Normalize recipients to array (preserve order) const recipients: string[] = Array.isArray(recipient) ? recipient : [recipient] @@ -155,24 +168,33 @@ export default { return 'payment_required' } - for (const r of recipients) { - const recipientFee = await getRecipientFee(r, sender, messageBox) - const status = feeToStatus(recipientFee) - - quotesByRecipient.push({ - recipient: r, - messageBox, - deliveryFee: perMessageDeliveryFee, - recipientFee, - status - }) - - // Aggregate: count deliveryFee per intended message, and recipientFee if not blocked - totalDeliveryFees += perMessageDeliveryFee - if (recipientFee === -1) { - blockedRecipients.push(r) - } else { - totalRecipientFees += recipientFee + for (let offset = 0; offset < MAX_QUOTE_RECIPIENTS; offset += QUOTE_CONCURRENCY) { + const batch = recipients.slice(offset, offset + QUOTE_CONCURRENCY) + if (batch.length === 0) break + + const recipientFees = await Promise.all( + batch.map(async recipientKey => ({ + recipient: recipientKey, + recipientFee: await getRecipientFee(recipientKey, sender, messageBox) + })) + ) + + for (const { recipient: recipientKey, recipientFee } of recipientFees) { + const status = feeToStatus(recipientFee) + quotesByRecipient.push({ + recipient: recipientKey, + messageBox, + deliveryFee: perMessageDeliveryFee, + recipientFee, + status + }) + + totalDeliveryFees += perMessageDeliveryFee + if (recipientFee === -1) { + blockedRecipients.push(recipientKey) + } else { + totalRecipientFees += recipientFee + } } } diff --git a/infra/message-box-server/src/routes/permissions/index.ts b/infra/message-box-server/src/routes/permissions/index.ts index 22d3289aa..343df40c1 100644 --- a/infra/message-box-server/src/routes/permissions/index.ts +++ b/infra/message-box-server/src/routes/permissions/index.ts @@ -4,9 +4,4 @@ import getPermission from './getPermission.js' import getQuote from './getQuote.js' import listPermissions from './listPermissions.js' -export const permissionRoutes = [ - setPermission, - getPermission, - getQuote, - listPermissions -] +export const permissionRoutes = [setPermission, getPermission, getQuote, listPermissions] diff --git a/infra/message-box-server/src/routes/permissions/listPermissions.ts b/infra/message-box-server/src/routes/permissions/listPermissions.ts index a1be29936..12be66eb1 100644 --- a/infra/message-box-server/src/routes/permissions/listPermissions.ts +++ b/infra/message-box-server/src/routes/permissions/listPermissions.ts @@ -3,6 +3,10 @@ import { AuthRequest } from '@bsv/auth-express-middleware' import { Logger } from '../../utils/logger.js' import { runtimeDeps } from '../../runtimeDeps.js' +export const MAX_PERMISSION_PAGE_SIZE = 100 +export const MAX_PERMISSION_OFFSET = 100_000 +const MAX_MESSAGE_BOX_BYTES = 128 + export interface ListPermissionsRequest extends AuthRequest { query: { messageBox?: string // Optional messageBox filter @@ -33,7 +37,7 @@ export interface ListPermissionsRequest extends AuthRequest { * schema: * type: integer * minimum: 1 - * maximum: 1000 + * maximum: 100 * default: 100 * description: Maximum number of permissions to return * - in: query @@ -120,51 +124,69 @@ export default { // Parse and validate query parameters const { messageBox, limit: limitStr, offset: offsetStr, createdAtOrder } = req.query - const limit = limitStr != null ? Number.parseInt(limitStr, 10) : 100 - const offset = offsetStr != null ? Number.parseInt(offsetStr, 10) : 0 - const sortOrder = createdAtOrder === 'asc' ? 'asc' : 'desc' // Default to 'desc' + const limit = limitStr != null ? Number(limitStr) : MAX_PERMISSION_PAGE_SIZE + const offset = offsetStr != null ? Number(offsetStr) : 0 + const sortOrder = createdAtOrder ?? 'desc' // Validate pagination parameters - if (Number.isNaN(limit) || limit < 1 || limit > 1000) { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_PERMISSION_PAGE_SIZE) { return res.status(400).json({ status: 'error', code: 'ERR_INVALID_LIMIT', - description: 'Limit must be a number between 1 and 1000' + description: `limit must be an integer between 1 and ${MAX_PERMISSION_PAGE_SIZE}.` }) } - if (Number.isNaN(offset) || offset < 0) { + if (!Number.isSafeInteger(offset) || offset < 0 || offset > MAX_PERMISSION_OFFSET) { return res.status(400).json({ status: 'error', code: 'ERR_INVALID_OFFSET', - description: 'Offset must be a non-negative number' + description: `offset must be an integer between 0 and ${MAX_PERMISSION_OFFSET}.` + }) + } + + if (sortOrder !== 'asc' && sortOrder !== 'desc') { + return res.status(400).json({ + status: 'error', + code: 'ERR_INVALID_SORT_ORDER', + description: 'createdAtOrder must be asc or desc.' + }) + } + + if ( + messageBox != null && + (typeof messageBox !== 'string' || + messageBox.trim() === '' || + Buffer.byteLength(messageBox.trim(), 'utf8') > MAX_MESSAGE_BOX_BYTES) + ) { + return res.status(400).json({ + status: 'error', + code: 'ERR_INVALID_MESSAGE_BOX', + description: `messageBox must be a non-empty string of at most ${MAX_MESSAGE_BOX_BYTES} bytes.` }) } // Validate identity key format const recipientKey = req.auth.identityKey - Logger.log(`[DEBUG] Listing permissions for recipient: ${recipientKey}, messageBox: ${messageBox ?? 'all'}, limit: ${limit}, offset: ${offset}, createdAtOrder: ${sortOrder}`) + Logger.log( + `[DEBUG] Listing permissions for recipient: ${recipientKey}, messageBox: ${messageBox ?? 'all'}, limit: ${limit}, offset: ${offset}, createdAtOrder: ${sortOrder}` + ) // Build base query - let query = runtimeDeps.knex('message_permissions') - .select([ - 'sender', - 'message_box', - 'recipient_fee', - 'created_at', - 'updated_at' - ]) + let query = runtimeDeps + .knex('message_permissions') + .select(['sender', 'message_box', 'recipient_fee', 'created_at', 'updated_at']) .where('recipient', recipientKey) .orderBy([ { column: 'message_box', order: 'asc' }, - { column: 'sender', order: 'asc', nulls: 'first' }, // Box-wide (null) first + { column: 'sender_scope', order: 'asc' }, // Box-wide (empty scope) first { column: 'created_at', order: sortOrder } ]) // Apply messageBox filter if provided if (messageBox != null) { - query = query.where('message_box', messageBox) + query = query.where('message_box', messageBox.trim()) } // Get total count for pagination info (before applying limit/offset) @@ -173,9 +195,7 @@ export default { const total = Number.parseInt(String(totalCount), 10) // Apply pagination - const permissions = await query - .limit(limit) - .offset(offset) + const permissions = await query.limit(limit).offset(offset) Logger.log(`[DEBUG] Found ${permissions.length} permissions (${total} total)`) diff --git a/infra/message-box-server/src/routes/permissions/setPermission.ts b/infra/message-box-server/src/routes/permissions/setPermission.ts index 571e35167..05852dc54 100644 --- a/infra/message-box-server/src/routes/permissions/setPermission.ts +++ b/infra/message-box-server/src/routes/permissions/setPermission.ts @@ -4,6 +4,9 @@ import { Logger } from '../../utils/logger.js' import { AuthRequest } from '@bsv/auth-express-middleware' import { setMessagePermission } from '../../utils/messagePermissions.js' +export const MAX_PERMISSION_MESSAGE_BOX_BYTES = 128 +export const MAX_RECIPIENT_FEE = 2_147_483_647 + export interface SetPermissionRequestType extends AuthRequest { body: { sender?: string // Optional - if not provided, sets box-wide default @@ -75,7 +78,8 @@ export default { return res.status(400).json({ status: 'error', code: 'ERR_INVALID_REQUEST', - description: 'messageBox (string) and recipientFee (number) are required. sender (string) is optional for box-wide settings.' + description: + 'messageBox (string) and recipientFee (number) are required. sender (string) is optional for box-wide settings.' }) } @@ -94,27 +98,40 @@ export default { } // Validate recipientFee value - if (!Number.isInteger(recipientFee)) { + if ( + !Number.isSafeInteger(recipientFee) || + recipientFee < -1 || + recipientFee > MAX_RECIPIENT_FEE + ) { Logger.log('[DEBUG] Invalid recipientFee value - must be integer') return res.status(400).json({ status: 'error', code: 'ERR_INVALID_FEE_VALUE', - description: 'recipientFee must be an integer (-1, 0, or positive number).' + description: `recipientFee must be -1, 0, or a positive integer no greater than ${MAX_RECIPIENT_FEE}.` }) } // Validate messageBox value - if (typeof messageBox !== 'string' || messageBox.trim() === '') { + if ( + typeof messageBox !== 'string' || + messageBox.trim() === '' || + Buffer.byteLength(messageBox.trim(), 'utf8') > MAX_PERMISSION_MESSAGE_BOX_BYTES + ) { Logger.log('[DEBUG] Invalid messageBox value') return res.status(400).json({ status: 'error', code: 'ERR_INVALID_MESSAGE_BOX', - description: 'messageBox must be a non-empty string.' + description: `messageBox must be a non-empty string of at most ${MAX_PERMISSION_MESSAGE_BOX_BYTES} bytes.` }) } // Set the message permission (convert undefined sender to null for box-wide) - const success = await setMessagePermission(recipient, sender ?? null, messageBox, recipientFee) + const success = await setMessagePermission( + recipient, + sender ?? null, + messageBox.trim(), + recipientFee + ) if (success == null) { return res.status(500).json({ @@ -125,7 +142,9 @@ export default { } const isBoxWide = sender == null - Logger.log(`[DEBUG] Successfully updated message permission: ${sender ?? 'BOX-WIDE'} -> ${recipient} (${messageBox}), fee: ${recipientFee}`) + Logger.log( + `[DEBUG] Successfully updated message permission: ${sender ?? 'BOX-WIDE'} -> ${recipient} (${messageBox}), fee: ${recipientFee}` + ) let description: string const senderText = isBoxWide ? 'all senders' : sender diff --git a/infra/message-box-server/src/routes/registerDevice.ts b/infra/message-box-server/src/routes/registerDevice.ts index adae81158..00db44dbb 100644 --- a/infra/message-box-server/src/routes/registerDevice.ts +++ b/infra/message-box-server/src/routes/registerDevice.ts @@ -3,6 +3,9 @@ import { Logger } from '../utils/logger.js' import { AuthRequest } from '@bsv/auth-express-middleware' import { runtimeDeps } from '../runtimeDeps.js' +export const MAX_FCM_TOKEN_LENGTH = 500 +export const MAX_DEVICE_ID_LENGTH = 255 + export interface RegisterDeviceRequest extends AuthRequest { body: { fcmToken: string @@ -59,6 +62,8 @@ export interface RegisterDeviceRequest extends AuthRequest { * description: Invalid request parameters * 401: * description: Authentication required + * 409: + * description: The FCM token is already registered to another authenticated identity * 500: * description: Internal server error */ @@ -93,6 +98,26 @@ export default { }) } + const normalizedFcmToken = fcmToken.trim() + if (normalizedFcmToken.length > MAX_FCM_TOKEN_LENGTH) { + return res.status(400).json({ + status: 'error', + code: 'ERR_INVALID_FCM_TOKEN', + description: `fcmToken must not exceed ${MAX_FCM_TOKEN_LENGTH} characters.` + }) + } + + if ( + deviceId != null && + (typeof deviceId !== 'string' || deviceId.trim().length > MAX_DEVICE_ID_LENGTH) + ) { + return res.status(400).json({ + status: 'error', + code: 'ERR_INVALID_DEVICE_ID', + description: `deviceId must be a string of at most ${MAX_DEVICE_ID_LENGTH} characters.` + }) + } + // Validate platform if provided const validPlatforms = ['ios', 'android', 'web'] if (platform != null && !validPlatforms.includes(platform)) { @@ -105,30 +130,49 @@ export default { } try { - // Insert or update device registration + // A token is a credential-like delivery address. Never let one + // authenticated identity silently take a token already owned by + // another identity. const now = new Date() - const [deviceRegistrationId] = await runtimeDeps.knex('device_registrations') - .insert({ + const existing = await runtimeDeps + .knex('device_registrations') + .select('id', 'identity_key') + .where('fcm_token', normalizedFcmToken) + .first() + + if (existing != null && existing.identity_key !== identityKey) { + return res.status(409).json({ + status: 'error', + code: 'ERR_DEVICE_TOKEN_ALREADY_REGISTERED', + description: 'This device token is already registered to another identity.' + }) + } + + const values = { + device_id: deviceId?.trim() ?? null, + platform: platform ?? null, + updated_at: now, + active: true, + last_used: now + } + + let deviceRegistrationId: number + if (existing != null) { + await runtimeDeps.knex('device_registrations').where('id', existing.id).update(values) + deviceRegistrationId = existing.id + } else { + const [insertedId] = await runtimeDeps.knex('device_registrations').insert({ identity_key: identityKey, - fcm_token: fcmToken.trim(), - device_id: deviceId?.trim() ?? null, - platform: platform ?? null, + fcm_token: normalizedFcmToken, created_at: now, - updated_at: now, - active: true, - last_used: now - }) - .onConflict('fcm_token') - .merge({ - identity_key: identityKey, // Update identity key in case token was reassigned - device_id: deviceId?.trim() ?? null, - platform: platform ?? null, - updated_at: now, - active: true, - last_used: now + ...values }) + deviceRegistrationId = Number(insertedId) + } - Logger.log(`[DEBUG] Device registered successfully: ${identityKey} with token ending in ...${fcmToken.slice(-10)}`) + Logger.log( + `[DEBUG] Device registered successfully for authenticated identity with token ending in ...${normalizedFcmToken.slice(-10)}` + ) return res.status(200).json({ status: 'success', diff --git a/infra/message-box-server/src/routes/sendMessage.ts b/infra/message-box-server/src/routes/sendMessage.ts index 1a1518df3..9c9334647 100644 --- a/infra/message-box-server/src/routes/sendMessage.ts +++ b/infra/message-box-server/src/routes/sendMessage.ts @@ -24,12 +24,16 @@ import { OutputTagStringUnder300Bytes, PositiveIntegerOrZero, PubKeyHex, - PublicKey, + PublicKey } from '@bsv/sdk' import { Logger, log } from '../utils/logger.js' import { AuthRequest } from '@bsv/auth-express-middleware' import { sendFCMNotification } from '../utils/sendFCMNotification.js' -import { getRecipientFee, getServerDeliveryFee, shouldUseFCMDelivery } from '../utils/messagePermissions.js' +import { + getRecipientFee, + getServerDeliveryFee, + shouldUseFCMDelivery +} from '../utils/messagePermissions.js' import { runtimeDeps, getWallet } from '../runtimeDeps.js' // Type definition for the incoming message format @@ -170,7 +174,9 @@ export function calculateMessagePrice(message: string, _priority: boolean = fals export default { type: 'post', path: '/sendMessage', - get knex () { return runtimeDeps.knex }, + get knex() { + return runtimeDeps.knex + }, summary: "Use this route to send a message to a recipient's message box.", parameters: { message: { @@ -196,7 +202,14 @@ export default { try { const { message, payment } = req.body - log.info({ operation: 'message.send', message_box: message?.messageBox, has_payment: payment != null }, 'Received message send request') + log.info( + { + operation: 'message.send', + message_box: message?.messageBox, + has_payment: payment != null + }, + 'Received message send request' + ) if (message == null) { Logger.error('[ERROR] No message provided in request body!') @@ -208,7 +221,11 @@ export default { } if (typeof message.messageBox !== 'string' || message.messageBox.trim() === '') { - return res.status(400).json({ status: 'error', code: 'ERR_INVALID_MESSAGEBOX', description: 'Invalid message box.' }) + return res.status(400).json({ + status: 'error', + code: 'ERR_INVALID_MESSAGEBOX', + description: 'Invalid message box.' + }) } if (Buffer.byteLength(message.messageBox.trim(), 'utf8') > MAX_MESSAGE_BOX_BYTES) { return res.status(400).json({ @@ -222,7 +239,11 @@ export default { typeof message.body !== 'string' || (typeof message.body === 'string' && message.body.trim() === '') ) { - return res.status(400).json({ status: 'error', code: 'ERR_INVALID_MESSAGE_BODY', description: 'Invalid message body.' }) + return res.status(400).json({ + status: 'error', + code: 'ERR_INVALID_MESSAGE_BODY', + description: 'Invalid message body.' + }) } if (Buffer.byteLength(message.body, 'utf8') > MAX_MESSAGE_BODY_BYTES) { return res.status(413).json({ @@ -242,9 +263,7 @@ export default { description: 'Missing recipient(s). Provide "recipient" or "recipients".' }) } - const recipients: string[] = Array.isArray(recipientsRaw) - ? recipientsRaw - : [recipientsRaw] + const recipients: string[] = Array.isArray(recipientsRaw) ? recipientsRaw : [recipientsRaw] if (recipients.length === 0 || recipients.length > MAX_MESSAGE_RECIPIENTS) { return res.status(400).json({ status: 'error', @@ -261,9 +280,7 @@ export default { description: 'Missing messageId.' }) } - const messageIds: string[] = Array.isArray(messageIdRaw) - ? messageIdRaw - : [messageIdRaw] + const messageIds: string[] = Array.isArray(messageIdRaw) ? messageIdRaw : [messageIdRaw] // If multiple recipients but only one messageId provided, fail clearly (avoid accidental reuse) if (recipients.length > 1 && messageIds.length === 1) { @@ -288,7 +305,11 @@ export default { id.trim() === '' || Buffer.byteLength(id, 'utf8') > MAX_MESSAGE_ID_BYTES ) { - return res.status(400).json({ status: 'error', code: 'ERR_INVALID_MESSAGEID', description: 'Each messageId must be a non-empty string.' }) + return res.status(400).json({ + status: 'error', + code: 'ERR_INVALID_MESSAGEID', + description: 'Each messageId must be a non-empty string.' + }) } } @@ -313,10 +334,16 @@ export default { // Ensure messageBox exists for each recipient const boxType = message.messageBox.trim() for (const r of recipientsTrimmed) { - const existing = await runtimeDeps.knex('messageBox').where({ identityKey: r, type: boxType }).first() + const existing = await runtimeDeps + .knex('messageBox') + .where({ identityKey: r, type: boxType }) + .first() if (!existing) { await runtimeDeps.knex('messageBox').insert({ - identityKey: r, type: boxType, created_at: new Date(), updated_at: new Date() + identityKey: r, + type: boxType, + created_at: new Date(), + updated_at: new Date() }) } } @@ -324,11 +351,22 @@ export default { // ---------- Fee evaluation ---------- const deliveryFeeOnce = await getServerDeliveryFee(boxType) - type FeeRow = { recipient: string; recipientFee: number; allowed: boolean; blockedReason?: string } + type FeeRow = { + recipient: string + recipientFee: number + allowed: boolean + blockedReason?: string + } const feeRows: FeeRow[] = [] for (const r of recipientsTrimmed) { const rf = await getRecipientFee(r, senderKey, boxType) // -1 = blocked; 0 = allow; >0 = sats required - if (rf === -1) feeRows.push({ recipient: r, recipientFee: rf, allowed: false, blockedReason: `Messages to ${r} are blocked` }) + if (rf === -1) + feeRows.push({ + recipient: r, + recipientFee: rf, + allowed: false, + blockedReason: `Messages to ${r} are blocked` + }) else feeRows.push({ recipient: r, recipientFee: rf, allowed: true }) } @@ -344,7 +382,7 @@ export default { } const anyRecipientFee = feeRows.some(f => f.recipientFee > 0) - const requiresPayment = (deliveryFeeOnce > 0) || anyRecipientFee + const requiresPayment = deliveryFeeOnce > 0 || anyRecipientFee // ---------- Payment internalization (batch) ---------- const perRecipientOutputs = new Map() @@ -395,7 +433,14 @@ export default { // ---------- Build per-recipient outputs ---------- const recipientSideOutputs = payment.outputs.slice(deliveryFeeOnce > 0 ? 1 : 0) - log.info({ operation: 'message.send', recipient_output_count: recipientSideOutputs.length, total_output_count: payment.outputs.length }, 'Payment outputs') + log.info( + { + operation: 'message.send', + recipient_output_count: recipientSideOutputs.length, + total_output_count: payment.outputs.length + }, + 'Payment outputs' + ) const feeRecipients = feeRows.filter(f => f.recipientFee > 0).map(f => f.recipient) @@ -489,7 +534,8 @@ export default { // ---------- Store messages (one per recipient) ---------- const results: Array<{ recipient: string; messageId: string }> = [] for (const { recipient: r } of feeRows) { - const mb = await runtimeDeps.knex('messageBox') + const mb = await runtimeDeps + .knex('messageBox') .where({ identityKey: r, type: boxType }) .select('messageBoxId') .first() @@ -514,7 +560,8 @@ export default { } try { - await runtimeDeps.knex('messages') + await runtimeDeps + .knex('messages') .insert({ messageId: perRecipientMessageId, messageBoxId: mb?.messageBoxId ?? null, diff --git a/infra/message-box-server/src/runtimeDeps.ts b/infra/message-box-server/src/runtimeDeps.ts index 923607092..e36557a73 100644 --- a/infra/message-box-server/src/runtimeDeps.ts +++ b/infra/message-box-server/src/runtimeDeps.ts @@ -13,15 +13,12 @@ export const runtimeDeps: { knex: null as unknown as Knex } -export function bindMessageBoxRuntime (deps: { - knex: Knex - wallet?: WalletInterface -}): void { +export function bindMessageBoxRuntime(deps: { knex: Knex; wallet?: WalletInterface }): void { runtimeDeps.knex = deps.knex runtimeDeps.wallet = deps.wallet } -export async function getWallet (): Promise { +export async function getWallet(): Promise { if (runtimeDeps.wallet == null) { throw new Error('Wallet is not initialized') } diff --git a/infra/message-box-server/src/security/webSocketPolicy.test.ts b/infra/message-box-server/src/security/webSocketPolicy.test.ts index c0909e279..d11989739 100644 --- a/infra/message-box-server/src/security/webSocketPolicy.test.ts +++ b/infra/message-box-server/src/security/webSocketPolicy.test.ts @@ -12,19 +12,15 @@ describe('Message Box WebSocket policy', () => { const otherIdentity = PrivateKey.fromRandom().toPublicKey().toString() it('uses the signed transport identity and accepts a matching claim', () => { - expect(authenticatedWebSocketIdentity( - authenticatedIdentity, + expect(authenticatedWebSocketIdentity(authenticatedIdentity, authenticatedIdentity)).toBe( authenticatedIdentity - )).toBe(authenticatedIdentity) + ) }) it('rejects a payload identity that differs from the signed peer', () => { - expect(() => authenticatedWebSocketIdentity( - authenticatedIdentity, - otherIdentity - )).toThrow(new WebSocketPolicyError( - 'Identity claim does not match authenticated peer' - )) + expect(() => authenticatedWebSocketIdentity(authenticatedIdentity, otherIdentity)).toThrow( + new WebSocketPolicyError('Identity claim does not match authenticated peer') + ) }) it('rejects absent and malformed transport identities', () => { @@ -37,26 +33,20 @@ describe('Message Box WebSocket policy', () => { }) it('limits room access to a non-empty box owned by the identity', () => { - expect(isIdentityOwnedRoom( - authenticatedIdentity, - `${authenticatedIdentity}-payment_inbox` - )).toBe(true) + expect( + isIdentityOwnedRoom(authenticatedIdentity, `${authenticatedIdentity}-payment_inbox`) + ).toBe(true) expect(isIdentityOwnedRoom(authenticatedIdentity, `${authenticatedIdentity}-`)).toBe(false) - expect(isIdentityOwnedRoom( - authenticatedIdentity, - `${otherIdentity}-payment_inbox` - )).toBe(false) + expect(isIdentityOwnedRoom(authenticatedIdentity, `${otherIdentity}-payment_inbox`)).toBe(false) }) it('derives a message box only from the recipient-owned room', () => { - expect(messageBoxFromRecipientRoom( - authenticatedIdentity, - `${authenticatedIdentity}-payment_inbox` - )).toBe('payment_inbox') - expect(messageBoxFromRecipientRoom( - authenticatedIdentity, - `${otherIdentity}-payment_inbox` - )).toBeUndefined() + expect( + messageBoxFromRecipientRoom(authenticatedIdentity, `${authenticatedIdentity}-payment_inbox`) + ).toBe('payment_inbox') + expect( + messageBoxFromRecipientRoom(authenticatedIdentity, `${otherIdentity}-payment_inbox`) + ).toBeUndefined() }) it('selects only sockets authenticated as the recipient', () => { diff --git a/infra/message-box-server/src/security/webSocketPolicy.ts b/infra/message-box-server/src/security/webSocketPolicy.ts index 188345096..ee7765655 100644 --- a/infra/message-box-server/src/security/webSocketPolicy.ts +++ b/infra/message-box-server/src/security/webSocketPolicy.ts @@ -1,7 +1,7 @@ import { PublicKey } from '@bsv/sdk' export class WebSocketPolicyError extends Error { - constructor (public readonly reason: string) { + constructor(public readonly reason: string) { super(reason) this.name = 'WebSocketPolicyError' } @@ -11,7 +11,7 @@ export class WebSocketPolicyError extends Error { * Accept only the identity discovered by the signed BRC-103 transport. An * optional payload claim may confirm that identity, but can never replace it. */ -export function authenticatedWebSocketIdentity ( +export function authenticatedWebSocketIdentity( transportIdentity: unknown, claimedIdentity?: unknown ): string { @@ -26,26 +26,20 @@ export function authenticatedWebSocketIdentity ( throw new WebSocketPolicyError('Invalid authenticated identity key') } - if ( - typeof claimedIdentity === 'string' && - claimedIdentity.trim() !== normalizedIdentity - ) { + if (typeof claimedIdentity === 'string' && claimedIdentity.trim() !== normalizedIdentity) { throw new WebSocketPolicyError('Identity claim does not match authenticated peer') } return normalizedIdentity } -export function isIdentityOwnedRoom ( - identityKey: string, - roomId: unknown -): roomId is string { +export function isIdentityOwnedRoom(identityKey: string, roomId: unknown): roomId is string { if (typeof roomId !== 'string' || roomId.trim() === '') return false const prefix = `${identityKey}-` return roomId.startsWith(prefix) && roomId.length > prefix.length } -export function messageBoxFromRecipientRoom ( +export function messageBoxFromRecipientRoom( recipient: string, roomId: unknown ): string | undefined { @@ -53,7 +47,7 @@ export function messageBoxFromRecipientRoom ( return roomId.slice(recipient.length + 1) } -export function recipientSocketIds ( +export function recipientSocketIds( authenticatedSockets: ReadonlyMap, recipientIdentity: string ): string[] { diff --git a/infra/message-box-server/src/swagger.ts b/infra/message-box-server/src/swagger.ts index 093e7331f..3f5891e9f 100644 --- a/infra/message-box-server/src/swagger.ts +++ b/infra/message-box-server/src/swagger.ts @@ -1,15 +1,16 @@ import swaggerJsdoc from 'swagger-jsdoc' import swaggerUi from 'swagger-ui-express' -import { Express } from 'express' +import { Express, type RequestHandler } from 'express' -export function setupSwagger (app: Express): void { +export function setupSwagger(app: Express): void { const options = { definition: { openapi: '3.0.0', info: { title: 'MessageBox Server API', version: '1.0.0', - description: 'API documentation for the MessageBox Server, including message delivery, retrieval, acknowledgment, and overlay routing.' + description: + 'API documentation for the MessageBox Server, including message delivery, retrieval, acknowledgment, and overlay routing.' }, servers: [ { @@ -17,34 +18,36 @@ export function setupSwagger (app: Express): void { description: 'Local Development Server' }, { - url: 'https://messagebox.babbage.systems', + url: 'https://message-box-us-1.bsvb.tech', description: 'Production MessageBox Server' } ], components: { securitySchemes: { - BearerAuth: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - description: 'Provide your signed identity key JWT in the `Authorization` header as: `Bearer `' + BsvMutualAuth: { + type: 'apiKey', + in: 'header', + name: 'x-bsv-auth-identity-key', + description: + 'BRC-103 mutual authentication over the BRC-104 HTTP binding. The complete x-bsv-auth-* header family is required.' } } }, security: [ { - BearerAuth: [] + BsvMutualAuth: [] } ] }, - apis: ['./src/routes/*.ts'] + apis: ['./src/routes/**/*.ts'] } const swaggerSpec = swaggerJsdoc(options) - // Swagger UI at /docs — cast needed for @types/swagger-ui-express v4 + @types/express v5 compat - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(app as any).use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)) + // Casts bridge @types/swagger-ui-express v4 to Express v5's handler types. + const swaggerHandlers = swaggerUi.serve as unknown as RequestHandler[] + const swaggerSetup = swaggerUi.setup(swaggerSpec) as unknown as RequestHandler + app.use('/docs', ...swaggerHandlers, swaggerSetup) // Serve raw OpenAPI spec at /openapi.json app.get('/openapi.json', (_req, res) => { diff --git a/infra/message-box-server/src/telemetry.ts b/infra/message-box-server/src/telemetry.ts index 8a627dbfc..a7b685985 100644 --- a/infra/message-box-server/src/telemetry.ts +++ b/infra/message-box-server/src/telemetry.ts @@ -27,27 +27,24 @@ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http' import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http' import { - ConsoleSpanExporter, - BatchSpanProcessor, - SimpleSpanProcessor, - type SpanExporter, + ConsoleSpanExporter, + BatchSpanProcessor, + SimpleSpanProcessor, + type SpanExporter } from '@opentelemetry/sdk-trace-base' import { - PeriodicExportingMetricReader, - ConsoleMetricExporter, - type PushMetricExporter, + PeriodicExportingMetricReader, + ConsoleMetricExporter, + type PushMetricExporter } from '@opentelemetry/sdk-metrics' import { - BatchLogRecordProcessor, - SimpleLogRecordProcessor, - ConsoleLogRecordExporter, - type LogRecordExporter, + BatchLogRecordProcessor, + SimpleLogRecordProcessor, + ConsoleLogRecordExporter, + type LogRecordExporter } from '@opentelemetry/sdk-logs' import { resourceFromAttributes } from '@opentelemetry/resources' -import { - ATTR_SERVICE_NAME, - ATTR_SERVICE_VERSION, -} from '@opentelemetry/semantic-conventions' +import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions' import { logs, SeverityNumber } from '@opentelemetry/api-logs' import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api' @@ -61,47 +58,43 @@ const useOtlp = typeof otlpEndpoint === 'string' && otlpEndpoint.length > 0 const env = process.env.DEPLOY_ENV ?? process.env.NODE_ENV ?? 'development' if (process.env.OTEL_DIAG === 'true') { - diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO) + diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO) } const resource = resourceFromAttributes({ - [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? pkg.name, - [ATTR_SERVICE_VERSION]: pkg.version, - 'deployment.environment': env, + [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? pkg.name, + [ATTR_SERVICE_VERSION]: pkg.version, + 'deployment.environment': env }) -const traceExporter: SpanExporter = useOtlp - ? new OTLPTraceExporter() - : new ConsoleSpanExporter() +const traceExporter: SpanExporter = useOtlp ? new OTLPTraceExporter() : new ConsoleSpanExporter() const metricExporter: PushMetricExporter = useOtlp - ? new OTLPMetricExporter() - : new ConsoleMetricExporter() + ? new OTLPMetricExporter() + : new ConsoleMetricExporter() const logExporter: LogRecordExporter = useOtlp - ? new OTLPLogExporter() - : new ConsoleLogRecordExporter() + ? new OTLPLogExporter() + : new ConsoleLogRecordExporter() const logRecordProcessor = useOtlp - ? new BatchLogRecordProcessor({ exporter: logExporter }) - : new SimpleLogRecordProcessor({ exporter: logExporter }) + ? new BatchLogRecordProcessor({ exporter: logExporter }) + : new SimpleLogRecordProcessor({ exporter: logExporter }) const sdk = new NodeSDK({ - resource, - spanProcessors: [ - useOtlp - ? new BatchSpanProcessor(traceExporter) - : new SimpleSpanProcessor(traceExporter), - ], - metricReader: new PeriodicExportingMetricReader({ - exporter: metricExporter, - exportIntervalMillis: Number(process.env.OTEL_METRIC_EXPORT_INTERVAL ?? 60000), + resource, + spanProcessors: [ + useOtlp ? new BatchSpanProcessor(traceExporter) : new SimpleSpanProcessor(traceExporter) + ], + metricReader: new PeriodicExportingMetricReader({ + exporter: metricExporter, + exportIntervalMillis: Number(process.env.OTEL_METRIC_EXPORT_INTERVAL ?? 60000) + }), + logRecordProcessors: [logRecordProcessor], + instrumentations: [ + getNodeAutoInstrumentations({ + '@opentelemetry/instrumentation-fs': { enabled: false } }), - logRecordProcessors: [logRecordProcessor], - instrumentations: [ - getNodeAutoInstrumentations({ - '@opentelemetry/instrumentation-fs': { enabled: false }, - }), - new RuntimeNodeInstrumentation(), - ], + new RuntimeNodeInstrumentation() + ] }) sdk.start() @@ -114,45 +107,52 @@ const rawError = console.error.bind(console) // migrated to the structured (pino) logger. const logger = logs.getLogger(pkg.name, pkg.version) const SEVERITY: Record = { - debug: SeverityNumber.DEBUG, - info: SeverityNumber.INFO, - log: SeverityNumber.INFO, - warn: SeverityNumber.WARN, - error: SeverityNumber.ERROR, + debug: SeverityNumber.DEBUG, + info: SeverityNumber.INFO, + log: SeverityNumber.INFO, + warn: SeverityNumber.WARN, + error: SeverityNumber.ERROR } for (const method of ['debug', 'info', 'log', 'warn', 'error'] as const) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const original = (console as any)[method].bind(console) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(console as any)[method] = (...args: any[]) => { - try { - logger.emit({ - severityNumber: SEVERITY[method], - severityText: method.toUpperCase(), - body: args - .map((a) => - typeof a === 'string' ? a : (() => { - try { return JSON.stringify(a) } catch { return String(a) } - })() - ) - .join(' '), - }) - } catch { - /* never let telemetry break logging */ - } - original(...args) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const original = (console as any)[method].bind(console) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(console as any)[method] = (...args: any[]) => { + try { + logger.emit({ + severityNumber: SEVERITY[method], + severityText: method.toUpperCase(), + body: args + .map(a => + typeof a === 'string' + ? a + : (() => { + try { + return JSON.stringify(a) + } catch { + return String(a) + } + })() + ) + .join(' ') + }) + } catch { + /* never let telemetry break logging */ } + original(...args) + } } // Flush telemetry on shutdown. Only force-exit if the app registered no handler // of its own for this signal — otherwise we let the app's shutdown drive exit. const shutdown = (signal: NodeJS.Signals) => { - sdk.shutdown() - .then(() => rawInfo(`[otel] flushed (${signal})`)) - .catch((err: unknown) => rawError('[otel] shutdown error', err)) - .finally(() => { - if (process.listeners(signal).length <= 1) process.exit(0) - }) + sdk + .shutdown() + .then(() => rawInfo(`[otel] flushed (${signal})`)) + .catch((err: unknown) => rawError('[otel] shutdown error', err)) + .finally(() => { + if (process.listeners(signal).length <= 1) process.exit(0) + }) } process.on('SIGTERM', () => shutdown('SIGTERM')) process.on('SIGINT', () => shutdown('SIGINT')) diff --git a/infra/message-box-server/src/types/mock-knex.d.ts b/infra/message-box-server/src/types/mock-knex.d.ts index c251f2249..dce41dc74 100644 --- a/infra/message-box-server/src/types/mock-knex.d.ts +++ b/infra/message-box-server/src/types/mock-knex.d.ts @@ -15,4 +15,4 @@ declare module 'mock-knex' { const mockKnex: MockKnex export default mockKnex -} \ No newline at end of file +} diff --git a/infra/message-box-server/src/utils/logger.ts b/infra/message-box-server/src/utils/logger.ts index 239b1263c..6adb840a0 100644 --- a/infra/message-box-server/src/utils/logger.ts +++ b/infra/message-box-server/src/utils/logger.ts @@ -9,23 +9,37 @@ const pinoLogger = pino({ level: process.env.LOG_LEVEL ?? 'info', base: { service: process.env.OTEL_SERVICE_NAME ?? pkg.name, - env: process.env.DEPLOY_ENV ?? process.env.NODE_ENV ?? 'development', + env: process.env.DEPLOY_ENV ?? process.env.NODE_ENV ?? 'development' }, // Scrub PII / credentials before records leave the process. on-chain data // (identity_key, txid) is public and intentionally NOT redacted. redact: { paths: [ - 'phone', 'phoneNumber', 'identifier', - 'presentation_key', 'presentationKey', 'payload', 'store', - 'password', 'pass', 'secret', 'privateKey', 'private_key', - 'authorization', 'token', 'access_token', - '*.phone', '*.phoneNumber', '*.identifier', '*.authorization', + 'phone', + 'phoneNumber', + 'identifier', + 'presentation_key', + 'presentationKey', + 'payload', + 'store', + 'password', + 'pass', + 'secret', + 'privateKey', + 'private_key', + 'authorization', + 'token', + 'access_token', + '*.phone', + '*.phoneNumber', + '*.identifier', + '*.authorization' ], - censor: '[redacted]', + censor: '[redacted]' }, formatters: { - level: (label) => ({ level: label }), - }, + level: label => ({ level: label }) + } }) // Export the raw pino logger for call sites that want structured fields: diff --git a/infra/message-box-server/src/utils/messagePermissions.ts b/infra/message-box-server/src/utils/messagePermissions.ts index 283ba42cf..1b3035500 100644 --- a/infra/message-box-server/src/utils/messagePermissions.ts +++ b/infra/message-box-server/src/utils/messagePermissions.ts @@ -18,17 +18,13 @@ export interface FeeCalculationResult { * Get server delivery fee for a message box type */ export async function getServerDeliveryFee(messageBox: string): Promise { - try { - const serverFee = await runtimeDeps.knex('server_fees') - .where({ message_box: messageBox }) - .select('delivery_fee') - .first() + const serverFee = await runtimeDeps + .knex('server_fees') + .where({ message_box: messageBox }) + .select('delivery_fee') + .first() - return serverFee?.delivery_fee ?? 0 - } catch (error) { - Logger.error('[ERROR] Error getting server delivery fee:', error) - return 0 - } + return serverFee?.delivery_fee ?? 0 } /** @@ -40,15 +36,13 @@ export async function getRecipientFee( messageBox: string ): Promise { try { - // Debug parameter types - Logger.log(`[DEBUG] getRecipientFee params - recipient: ${typeof recipient} (${JSON.stringify(recipient)}), sender: ${typeof sender} (${JSON.stringify(sender)}), messageBox: ${typeof messageBox} (${JSON.stringify(messageBox)})`) - // First try sender-specific permission if (sender != null) { - const senderSpecific = await runtimeDeps.knex('message_permissions') + const senderSpecific = await runtimeDeps + .knex('message_permissions') .where({ recipient: String(recipient), - sender: String(sender), + sender_scope: String(sender), message_box: String(messageBox) }) .select('recipient_fee') @@ -60,10 +54,11 @@ export async function getRecipientFee( } // Fallback to box-wide default - const boxWideDefault = await runtimeDeps.knex('message_permissions') + const boxWideDefault = await runtimeDeps + .knex('message_permissions') .where({ recipient: String(recipient), - sender: null, // Box-wide default + sender_scope: '', // Box-wide default message_box: String(messageBox) }) .select('recipient_fee') @@ -73,22 +68,14 @@ export async function getRecipientFee( return boxWideDefault.recipient_fee } - // Auto-create box-wide default if none exists + // Defaults are policy, not stored user preferences. Avoid inserting + // implicit rows on a read path (which can race and create duplicate + // box-wide NULL-sender rows in SQL databases). const defaultFee = getSmartDefaultFee(String(messageBox)) - await runtimeDeps.knex('message_permissions').insert({ - recipient: String(recipient), - sender: null, - message_box: String(messageBox), - recipient_fee: defaultFee, - created_at: new Date(), - updated_at: new Date() - }) - - Logger.log(`[DEBUG] Created box-wide default permission for ${recipient}/${messageBox} with fee ${defaultFee}`) return defaultFee } catch (error) { Logger.error('[ERROR] Error getting recipient fee:', error) - return 0 // Block on error + throw new Error('Unable to determine recipient permission') } } @@ -118,16 +105,18 @@ export async function setMessagePermission( const now = new Date() // Use upsert (insert or update) - await runtimeDeps.knex('message_permissions') + await runtimeDeps + .knex('message_permissions') .insert({ recipient, sender, + sender_scope: sender ?? '', message_box: messageBox, recipient_fee: recipientFee, created_at: now, updated_at: now }) - .onConflict(['recipient', 'sender', 'message_box']) + .onConflict(['recipient', 'message_box', 'sender_scope']) .merge({ recipient_fee: recipientFee, updated_at: now diff --git a/infra/message-box-server/src/utils/sendFCMNotification.ts b/infra/message-box-server/src/utils/sendFCMNotification.ts index b92269876..b7dbedc4b 100644 --- a/infra/message-box-server/src/utils/sendFCMNotification.ts +++ b/infra/message-box-server/src/utils/sendFCMNotification.ts @@ -33,7 +33,8 @@ export async function sendFCMNotification( Logger.log('[DEBUG] Payload:', payload) // Look up all active FCM tokens for this recipient - const deviceRegistrations = await runtimeDeps.knex('device_registrations') + const deviceRegistrations = await runtimeDeps + .knex('device_registrations') .where({ identity_key: recipient, active: true @@ -48,13 +49,19 @@ export async function sendFCMNotification( Logger.log(`[DEBUG] Found ${deviceRegistrations.length} active device(s) for ${recipient}`) // Send notification to all registered devices - const sendPromises = deviceRegistrations.map(async (device) => { + const sendPromises = deviceRegistrations.map(async device => { try { - Logger.log(`[DEBUG] Sending to ${device.platform ?? 'unknown'} device: ${device.device_id ?? 'unknown'}`) + Logger.log( + `[DEBUG] Sending to ${device.platform ?? 'unknown'} device: ${device.device_id ?? 'unknown'}` + ) const messaging = getFirebaseMessaging() if (messaging == null) { - return { success: false, token: device.fcm_token, error: 'Firebase Messaging not initialized (ENABLE_FIREBASE != true)' } + return { + success: false, + token: device.fcm_token, + error: 'Firebase Messaging not initialized (ENABLE_FIREBASE != true)' + } } await messaging.send({ @@ -75,44 +82,45 @@ export async function sendFCMNotification( apns: { headers: { 'apns-push-type': 'alert', // required for iOS 13+ - 'apns-priority': '10', // deliver immediately + 'apns-priority': '10' // deliver immediately // optional: 'apns-topic': '' // FCM fills this automatically }, payload: { aps: { 'mutable-content': 1, - alert: { // include an alert so NSE can modify it + alert: { + // include an alert so NSE can modify it title: payload.title, - body: payload.messageId, - }, + body: payload.messageId + } // do NOT set 'content-available': 1 unless you also want background fetch }, // custom keys your NSE can read: messageId: payload.messageId, - originator: payload.originator ?? 'unknown', - }, - }, + originator: payload.originator ?? 'unknown' + } + } }) // Update last_used timestamp on successful send - await runtimeDeps.knex('device_registrations') - .where('fcm_token', device.fcm_token) - .update({ - last_used: new Date(), - updated_at: new Date() - }) + await runtimeDeps.knex('device_registrations').where('fcm_token', device.fcm_token).update({ + last_used: new Date(), + updated_at: new Date() + }) return { success: true, token: device.fcm_token } } catch (error) { Logger.error(`[FCM ERROR] Failed to send to token ${device.fcm_token.slice(-10)}:`, error) // Mark token as inactive if it's invalid - if (error instanceof Error && ( - error.message.includes('registration-token-not-registered') || - error.message.includes('invalid-registration-token') - )) { + if ( + error instanceof Error && + (error.message.includes('registration-token-not-registered') || + error.message.includes('invalid-registration-token')) + ) { Logger.log(`[DEBUG] Marking invalid token as inactive: ...${device.fcm_token.slice(-10)}`) - await runtimeDeps.knex('device_registrations') + await runtimeDeps + .knex('device_registrations') .where('fcm_token', device.fcm_token) .update({ active: false, @@ -120,7 +128,11 @@ export async function sendFCMNotification( }) } - return { success: false, token: device.fcm_token, error: error instanceof Error ? error.message : String(error) } + return { + success: false, + token: device.fcm_token, + error: error instanceof Error ? error.message : String(error) + } } }) @@ -128,7 +140,9 @@ export async function sendFCMNotification( const successCount = results.filter(r => r.success).length const failureCount = results.length - successCount - Logger.log(`[DEBUG] FCM notification results: ${successCount} successful, ${failureCount} failed`) + Logger.log( + `[DEBUG] FCM notification results: ${successCount} successful, ${failureCount} failed` + ) // Consider it successful if at least one device received the notification if (successCount > 0) { diff --git a/infra/message-box-server/tsconfig.json b/infra/message-box-server/tsconfig.json index 30e92519c..763645172 100644 --- a/infra/message-box-server/tsconfig.json +++ b/infra/message-box-server/tsconfig.json @@ -1,9 +1,6 @@ { "compilerOptions": { - "lib": [ - "dom", - "ESNext" - ], + "lib": ["dom", "ESNext"], "module": "NodeNext", "target": "esnext", "moduleResolution": "NodeNext", @@ -23,14 +20,7 @@ "resolveJsonModule": true, "esModuleInterop": true }, - "files": [ - "src/index.ts", - ], - "include": [ - "src" - ], - "exclude": [ - "out", - "node_modules" - ] + "files": ["src/index.ts"], + "include": ["src"], + "exclude": ["out", "node_modules"] } diff --git a/packages/messaging/message-box-client/.github/workflows/push.yaml b/packages/messaging/message-box-client/.github/workflows/push.yaml deleted file mode 100644 index c93f87e49..000000000 --- a/packages/messaging/message-box-client/.github/workflows/push.yaml +++ /dev/null @@ -1,73 +0,0 @@ -name: Build and Test - -on: - push: - branches: - - master - pull_request: - - -jobs: - test: - name: Build & Test - runs-on: ubuntu-latest - permissions: - contents: read - strategy: - matrix: - node-version: [20, 22, 24] - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: ${{ matrix.node-version }} - registry-url: 'https://registry.npmjs.org' - - run: npm ci --ignore-scripts - - run: npm run lint:ci --if-present - - run: npm run test:coverage --if-present - - name: Upload coverage to Codecov - uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: ./coverage/lcov.info - flags: unittests - name: codecov-umbrella - fail_ci_if_error: false - publish: - name: Publish - needs: test - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - if: github.event_name == 'push' && github.ref == 'refs/heads/master' - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: '24' - registry-url: 'https://registry.npmjs.org' - - run: npm ci --ignore-scripts - - run: npm run build - - name: Check if version already published - id: check-version - run: | - PACKAGE_NAME=$(node -p "require('./package.json').name") - PACKAGE_VERSION=$(node -p "require('./package.json').version") - - echo "Package: $PACKAGE_NAME@$PACKAGE_VERSION" - - # Check if this version exists on npm - if npm view "$PACKAGE_NAME@$PACKAGE_VERSION" version 2>/dev/null; then - echo "Version $PACKAGE_VERSION already published" - echo "should_publish=false" >> $GITHUB_OUTPUT - else - echo "Version $PACKAGE_VERSION not yet published" - echo "should_publish=true" >> $GITHUB_OUTPUT - fi - - name: Publish to npm - if: steps.check-version.outputs.should_publish == 'true' - run: npm publish - - name: Skip publishing - if: steps.check-version.outputs.should_publish == 'false' - run: echo "Skipping publish - version already exists on npm" diff --git a/packages/messaging/message-box-client/.vscode/settings.json b/packages/messaging/message-box-client/.vscode/settings.json deleted file mode 100644 index 9c831b5ce..000000000 --- a/packages/messaging/message-box-client/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "standard.engine": "ts-standard" -} \ No newline at end of file diff --git a/packages/messaging/message-box-client/AGENTS.md b/packages/messaging/message-box-client/AGENTS.md index 89b9e57c9..e9f59bfe0 100644 --- a/packages/messaging/message-box-client/AGENTS.md +++ b/packages/messaging/message-box-client/AGENTS.md @@ -1,172 +1,101 @@ -# CLAUDE.md — @bsv/message-box-client - -## Purpose -Toolkit for peer-to-peer messaging and Bitcoin SV payments via store-and-forward architecture. Provides `MessageBoxClient` (message inbox management) and `PeerPayClient` (higher-level P2P payments) leveraging BRC-103 mutual authentication and BRC-29 payment derivation. - -## Public API surface - -### MessageBoxClient -- **Constructor**: `new MessageBoxClient(options)` - - Options: `walletClient` (BRC-100 wallet), `host` (MessageBox server URL), `enableLogging`, `networkPreset` ('local'|'mainnet'|'testnet') - - Auto-initializes on first use if needed - -- **Methods**: - - `init(targetHost?)` — manually initialize (optional, auto-runs) - - `sendMessage({ recipient, messageBox, body, skipEncryption? })` — send via HTTP - - `sendLiveMessage(...)` — send via WebSocket, fallback to HTTP - - `listMessages({ messageBox })` — retrieve messages from inbox - - `acknowledgeMessage({ messageIds })` — delete messages after reading - - `listenForLiveMessages({ messageBox, onMessage })` — subscribe to push updates - - `initializeConnection()` — establish WebSocket link - - `joinRoom(messageBox)` / `leaveRoom(messageBox)` — room subscription - - `resolveHostForRecipient(identityKey)` — overlay discovery for peer's MessageBox host - - `anointHost(url)` — advertise your MessageBox host to overlay network - -### PeerPayClient -- **Constructor**: `new PeerPayClient(options)` - - Options: `walletClient`, `messageBoxHost`, `enableLogging` - -- **Methods**: - - `sendPayment({ recipient, amount })` — HTTP payment - - `sendLivePayment({ recipient, amount })` — WebSocket payment - - `listenForLivePayments({ onPayment })` — listen for incoming payments - - `acceptPayment(payment)` — internalize into wallet - - `rejectPayment(payment)` — send refund (minus 1000 sats) - - `listIncomingPayments()` — list pending payments - -## Real usage patterns - -From README: -```ts -const { WalletClient } = require('@bsv/sdk') -const { MessageBoxClient } = require('@bsv/message-box-client') - -const myWallet = new WalletClient() -const msgBoxClient = new MessageBoxClient({ - host: 'https://message-box-us-1.bsvb.tech', - walletClient: myWallet -}) - -// Auto-init on first use, or manually -await msgBoxClient.init() - -// Send message to John -await msgBoxClient.sendMessage({ - recipient: '022600d2ef37d123fdcac7d25d7a464ada7acd3fb65a0daf85412140ee20884311', - messageBox: 'demo_inbox', - body: 'Hello John!' -}) - -// List John's messages -const messages = await msgBoxClient.listMessages({ messageBox: 'demo_inbox' }) -console.log(messages[0].body) - -// Acknowledge (delete) -await msgBoxClient.acknowledgeMessage({ - messageIds: messages.map(msg => msg.messageId.toString()) -}) - -// Listen for live messages -await msgBoxClient.listenForLiveMessages({ - messageBox: 'demo_inbox', - onMessage: (msg) => console.log('Live:', msg.body) -}) -``` +# Message Box Client Maintainer Guide + +## Scope + +`@bsv/message-box-client` is the browser- and Node-compatible client for the +Message Box HTTP/WebSocket protocol. It provides encrypted store-and-forward +messaging, BRC-29 peer payments, token-settlement adapters, delivery +permissions and quotes, and push-device registration. + +Public entry points are exported from `mod.ts`. Preserve both ESM and CommonJS +consumers and the UMD browser bundle. + +## Security and compatibility invariants + +- Messages are encrypted per recipient by default. Plaintext requires an + explicit `skipEncryption: true`. +- A shared multi-recipient body cannot be encrypted per recipient. The batch + API therefore requires explicit plaintext opt-in; notification convenience + APIs send bounded individual encrypted requests. +- Explicitly configured hosts may use HTTP for local/operator-controlled + environments. Overlay-advertised hosts are untrusted and must be public + HTTPS destinations; reject loopback, private, link-local, reserved, and + documentation-only targets. +- Preserve configured URL path prefixes when constructing route URLs. +- BRC-103 authentication, recipient ownership, permission/payment checks, and + encryption are the access boundaries. Message Box is a public protocol + service; compatible servers use credential-free wildcard CORS by default and + may opt into an exact allowlist. +- Keep `sendMesagetoRecepients` as a deprecated compatibility wrapper around + the correctly spelled `sendMessageToRecipients`. +- Never silently contact a deployed integration target. Live integration tests + require explicit environment configuration and a separate acknowledgement + for `*.bsvb.tech`. + +## Server contract + +Authenticated routes: + +- `POST /sendMessage` +- `POST /listMessages` (bounded pagination) +- `POST /acknowledgeMessage` +- `POST /registerDevice` +- `GET /devices` +- `POST /permissions/set` +- `GET /permissions/get` +- `GET /permissions/list` +- `GET /permissions/quote` + +Live delivery uses authenticated Socket.IO rooms named +`{identityKey}-{messageBox}`. The reviewed source contract is +`specs/messaging/message-box-http.yaml`. -PeerPayClient: -```ts -import { WalletClient } from '@bsv/sdk' -import { PeerPayClient } from '@bsv/message-box-client' - -const wallet = new WalletClient() -const peerPay = new PeerPayClient({ walletClient: wallet }) - -// Listen for incoming payments -await peerPay.listenForLivePayments({ - onPayment: async (payment) => { - console.log('Received payment:', payment) - await peerPay.acceptPayment(payment) - } -}) - -// Send 50,000 sats -await peerPay.sendLivePayment({ - recipient: '0277a2b...e3f4', - amount: 50000 -}) -``` +## File map -From tests: -```ts -const mockWalletClient = new WalletClient() -mockWalletClient.getPublicKey.mockResolvedValue({ - publicKey: PrivateKey.fromRandom().toPublicKey().toString() -}) - -const peerPayClient = new PeerPayClient({ - messageBoxHost: 'https://message-box-us-1.bsvb.tech', - walletClient: mockWalletClient -}) - -const payment = { recipient: PrivateKey.fromRandom().toPublicKey().toString(), amount: 5 } -const token = await peerPayClient.createPaymentToken(payment) -expect(token).toHaveProperty('amount', 5) +- `mod.ts` — public exports +- `src/MessageBoxClient.ts` — base HTTP, WebSocket, overlay, permission, quote, + device, and message APIs +- `src/PeerPayClient.ts` — BRC-29 payments and payment requests +- `src/PeerTokenClient.ts` — token transfer/request transport +- `src/TokenSettlementAdapter.ts` — token-standard adapter contract +- `src/RemittanceAdapter.ts` — SDK remittance integration +- `src/host.ts` — configured-host and untrusted-overlay URL policy +- `src/types.ts`, `src/types/permissions.ts` — public types +- `src/__tests/` — deterministic unit and contract tests +- `src/__tests/integration/` — explicitly configured live tests +- `tsdown.config.ts` — unbundled ESM/CommonJS/declaration build +- `webpack.config.js` — UMD build +- `browser-budget.json` — Vite, esbuild, and UMD size/composition budgets + +## Required checks + +Run from the repository root: + +```bash +pnpm --filter @bsv/message-box-client typecheck +pnpm --filter @bsv/message-box-client format:check +pnpm --filter @bsv/message-box-client lint +pnpm --filter @bsv/message-box-client test +pnpm --filter @bsv/message-box-client test:coverage +pnpm --filter @bsv/message-box-client pack:check +pnpm --filter @bsv/message-box-client test:browser ``` -## Key concepts - -- **Store-and-forward**: Messages posted to named "inboxes" on server; recipient polls or subscribes via WebSocket -- **Ephemeral storage**: Messages deleted once acknowledged by recipient -- **Message encryption**: AES-256-GCM by default; `skipEncryption` flag disables -- **BRC-103 auth**: All requests signed with wallet identity; server verifies sender -- **Message box naming**: Arbitrary string per inbox (e.g., 'inbox', 'payment_inbox', 'notifications') -- **BRC-29 derivation**: Payment addresses derived from sender+recipient identity keys -- **Overlay network**: Can discover peers' MessageBox hosts via decentralized overlay service -- **Live vs HTTP**: WebSocket for push notifications; HTTP for polling - -## Dependencies - -- `@bsv/authsocket-client` ^2.0.2 — WebSocket auth -- `@bsv/sdk` ^2.0.14 — Wallet, crypto, auth -- Dev: jest, ts-jest, Oxlint, webpack, supertest - -## Common pitfalls / gotchas +The integration suite additionally requires: -1. **Auto-init now default** — `init()` is optional but recommended for explicit control; first use auto-initializes -2. **Encryption on by default** — message bodies are AES-256-GCM encrypted; set `skipEncryption: true` for raw data -3. **Message ID format** — treat messageId as string; don't assume numeric -4. **Live vs HTTP tradeoff** — WebSocket faster but requires connection; HTTP more reliable for offline use -5. **Payment rejection** — rejecting a payment sends a refund minus 1000 sats; if amount < 1000, payment is just dropped -6. **Overlay discovery** — if no explicit host provided, client queries overlay network; this takes ~10s per peer -7. **Room subscription** — WebSocket requires explicit `joinRoom()` before `listenForLiveMessages()` - -## Spec conformance - -- **BRC-103** (Peer-to-Peer Mutual Authentication): All messages signed/verified; handshake per-connection -- **BRC-29** (Payment Derivation): Payment address derivation using sender+recipient keys -- **BRC-100** (Wallet interface): Uses standard wallet methods for signing/verifying -- **MessageBox server protocol**: Custom HTTP + WebSocket endpoints for store-and-forward - -## File map - -``` -/Users/personal/git/ts-stack/packages/messaging/message-box-client/ - src/ - index.ts — main exports - MessageBoxClient.ts — store-and-forward client - PeerPayClient.ts — higher-level payment client - types.ts — interfaces (PeerMessage, SendMessageParams, etc.) - authFetch.ts — HTTP request signing - tests/ - PeerPayClientUnit.test.ts - integration/ - integrationWS.test.ts - integrationOverlay.test.ts +```bash +MESSAGE_BOX_RUN_INTEGRATION=true \ +MESSAGE_BOX_INTEGRATION_HOST=http://127.0.0.1:8080 \ +MESSAGE_BOX_WALLET_ORIGINATOR=localhost \ +pnpm --filter @bsv/message-box-client test:integration ``` -## Integration points +Do not weaken browser budgets to hide growth. Update a budget only with measured +artifact evidence and an explanation. + +## Release contract -- **authsocket-client** — underlying WebSocket with BRC-103 auth for live features -- **@bsv/sdk** — wallet, identity keys, crypto, AuthFetch for HTTP signing -- **MessageBox server** (infrastructure repo) — store-and-forward backend -- **Overlay network** — LARS service for peer discovery +The tarball contains compiled `dist/` artifacts, declarations, source maps, +README, and license only. It must not contain source tests, coverage, editor +files, package-manager locks, or repository workflows. Do not bump or publish a +version as an incidental part of maintenance work. diff --git a/packages/messaging/message-box-client/BASELINE.md b/packages/messaging/message-box-client/BASELINE.md index f44b4fe13..34bc9ad3a 100644 --- a/packages/messaging/message-box-client/BASELINE.md +++ b/packages/messaging/message-box-client/BASELINE.md @@ -3,48 +3,55 @@ > Captured: 2026-04-24. Reflects state at time of ts-stack migration. ## Identity -| Field | Value | -|-------|-------| -| Package | `@bsv/message-box-client` | -| Path | `packages/messaging/message-box-client` | -| npm | [@bsv/message-box-client](https://www.npmjs.com/package/@bsv/message-box-client) | -| Version | 2.1.1 | -| Criticality | **Tier 2** — client library for peer messaging; failure isolates to message delivery domain | -| Reliability Level | **RL2** — 8 test files, coverage tooling present | -| Owner | @sirdeggen | -| Backup owner | — | + +| Field | Value | +| ----------------- | ------------------------------------------------------------------------------------------- | +| Package | `@bsv/message-box-client` | +| Path | `packages/messaging/message-box-client` | +| npm | [@bsv/message-box-client](https://www.npmjs.com/package/@bsv/message-box-client) | +| Version | 2.1.1 | +| Criticality | **Tier 2** — client library for peer messaging; failure isolates to message delivery domain | +| Reliability Level | **RL2** — 8 test files, coverage tooling present | +| Owner | @sirdeggen | +| Backup owner | — | ## Build -| Field | Value | -|-------|-------| -| Build command | `npm run build:ts && npm run build:umd` | -| Build status | ✅ Passing (assumed — not yet verified in ts-stack CI) | -| Outputs | TypeScript compiled output + UMD bundle | + +| Field | Value | +| ------------- | ------------------------------------------------------ | +| Build command | `npm run build:ts && npm run build:umd` | +| Build status | ✅ Passing (assumed — not yet verified in ts-stack CI) | +| Outputs | TypeScript compiled output + UMD bundle | ## Tests -| Field | Value | -|-------|-------| -| Test command | `jest --config=jest.config.ts` | -| Test files | 8 | + +| Field | Value | +| ---------------- | ------------------------------------------------------------------- | +| Test command | `jest --config=jest.config.ts` | +| Test files | 8 | | Coverage command | `jest --coverage --coverageReporters=text --coverageReporters=html` | -| Coverage | Not yet captured as baseline | -| Known flaky | None identified | +| Coverage | Not yet captured as baseline | +| Known flaky | None identified | ## Lint -| Field | Value | -|-------|-------| -| Linter | Oxlint | + +| Field | Value | +| ------------ | ---------- | +| Linter | Oxlint | | Lint command | `oxlint .` | ## Dependencies -| Type | Count | Packages | -|------|-------|---------| -| Production | 2 | @bsv/authsocket-client, @bsv/sdk | + +| Type | Count | Packages | +| ---------- | ----- | -------------------------------- | +| Production | 2 | @bsv/authsocket-client, @bsv/sdk | ## Known Issues & Incidents + None recorded at migration time. ## Migration Gate Checklist (MBGA §13.3) + - [x] BASELINE.md captured - [ ] Conformance runner vectors passing - [ ] Contract tests green diff --git a/packages/messaging/message-box-client/README.md b/packages/messaging/message-box-client/README.md index c1e768712..8a34ce48a 100644 --- a/packages/messaging/message-box-client/README.md +++ b/packages/messaging/message-box-client/README.md @@ -1,1015 +1,247 @@ -# BSV Peer-to-Peer Messaging & Payment Tools +# @bsv/message-box-client -**@bsv/message-box-client** is a toolkit for **peer-to-peer messaging and payments** on the BSV blockchain. It leverages a server-side **store-and-forward** system for message delivery (via `MessageBoxClient`) and also includes a higher-level **peer-to-peer payment** flow (via `PeerPayClient`). Both functionalities build on [BRC-103](https://github.com/bitcoin-sv/BRCs/blob/master/peer-to-peer/0103.md) for mutual authentication and identity key management, allowing secure and authenticated exchanges of data and BSV. +Authenticated, optionally encrypted store-and-forward messaging for BSV +applications. The package supports browser and Node.js consumers, HTTP polling, +live authenticated WebSockets, overlay-based host discovery, peer payments, +token-settlement adapters, permissions, quotes, and push-device registration. -## Table of Contents +## Install -1. [Introduction](#1-introduction) -2. [Installation](#2-installation) - - [Running Integration Tests](#21-running-integration-tests) -3. [Overview](#3-overview) - - [MessageBoxClient](#31-messageboxclient-overview) - - [PeerPayClient](#32-peerpayclient-overview) -4. [Quick Start Examples](#4-quick-start-examples) - - [Using MessageBoxClient](#41-using-messageboxclient) - - [Using PeerPayClient](#42-using-peerpayclient) -5. [API Reference](#5-api-reference) - - [MessageBoxClient API](#51-messageboxclient-api) - - [PeerPayClient API](#52-peerpayclient-api) -6. [Contributing](#6-contributing) -7. [License](#7-license) - ---- - -## 1. Introduction - -The **@bsv/message-box-client** library provides two main tools for peer-to-peer interaction: - -1. **MessageBoxClient** – A store-and-forward messaging system backed by a "message box" server. It supports authenticated sending, listing, and acknowledging (deleting) messages with a mutual-auth approach. -2. **PeerPayClient** – A higher-level payment client built on top of `MessageBoxClient`, enabling real-time, peer-to-peer Bitcoin payments on the BSV blockchain. - -Both clients use the [BRC-103](https://github.com/bitcoin-sv/BRCs/blob/master/peer-to-peer/0103.md)-based authentication model. By integrating with a [WalletClient](https://github.com/bitcoin-sv), they can sign and verify messages, ensuring only authorized parties can send and receive. - -The examples below use the public BSV Blockchain Association MessageBox endpoint where a concrete host is useful. This is a service default, not a protocol requirement; applications can use their own MessageBox server or another compatible provider. - ---- - -## 2. Installation - -```bash -npm install @bsv/message-box-client -``` - -The package exports both `MessageBoxClient` and `PeerPayClient`. You can import them individually in your JavaScript/TypeScript applications. - ---- - -### 2.1. Running Integration Tests -The P2P integration tests verify live communication between the MessageBoxClient and a running instance of the MessageBox Server. To run them, make sure the following are set up: - -**Prerequisites** -1. MessageBox Server Running Locally -You must have a local instance of the [MessageBox Server](https://github.com/bitcoin-sv/messagebox-server.git) running. -Follow these steps inside the messagebox-server project: -```bash -git clone https://github.com/bitcoin-sv/messagebox-server.git -cd messagebox-server -cp .env.example .env -npm install -npm run start # Starts LARS (overlay service on http://localhost:8080) -npm run dev # Starts MessageBox Server (HTTP API on http://localhost:5001) -``` - -2. Ensure Environment Configuration -Your .env file in the MessageBox Server must have: ```bash -NODE_ENV=development -BSV_NETWORK=local -ENABLE_WEBSOCKETS=true -``` -and valid values for: - - SERVER_PRIVATE_KEY - - WALLET_STORAGE_URL - - MONGO_URI and MONGO_DB - - (Optional) If using MySQL locally, ensure KNEX_DB_CONNECTION is properly set. - -3. Wallet Storage Running (optional) -If your .env points to a local wallet-storage instance, make sure it is also running. - -________________________________________ - -**Running the Tests** -Once the MessageBox Server and overlay service are running: -```bash -npm run test:integration -``` - -This will execute all integration tests under src/__tests__/integration/, including HTTP, WebSocket, encryption, and overlay scenarios. - -________________________________________ - -**Notes** -- If the server is not running, tests will fail with network or timeout errors. -- Integration tests use the local network preset (networkPreset: 'local') and assume the default MessageBox API endpoints (e.g., http://localhost:8080). -- Some tests may require clearing the database manually between runs if data conflicts occur. -- Unit tests (non-integration) still run by default with npm test. Integration tests are separated. - -________________________________________ - -**Quick Summary** -- npm run test → unit tests only -- npm run test:integration → integration tests against a running MessageBox server - -________________________________________ - -## 3. Overview - -### 3.1. MessageBoxClient Overview - -`MessageBoxClient` uses a **store-and-forward** architecture for Peer-to-Peer messages: - -- **Store-and-forward:** Messages are posted to a MessageBoxServer under a named "message box" (like an inbox). -- **Ephemeral storage:** Once the recipient acknowledges the messages, they are removed from the server. -- **Mutual authentication:** Ensures only authorized peers can read or post messages, using [AuthFetch](https://github.com/bitcoin-sv/authfetch) and [AuthSocketClient](https://github.com/bitcoin-sv/authsocket). -- **Flexible transport:** Supports both **WebSockets** (for live, push-style delivery) and **HTTP** (for polling or fallback). -- **Extensible:** Can be the foundation for more advanced workflows (e.g., token-based messaging, invoice/ticket systems, etc.). - -#### Key Features - -1. **Secure by default** using Auth libraries for signing/verification. -2. **Real-time or delayed** delivery with sockets or HTTP. -3. **Easy integration** with higher-level protocols and services. - ---- - -#### Important: Initialization Requirement - -**Important:** -Starting with version `@bsv/message-box-client@1.2.0`, `MessageBoxClient` **automatically initializes itself** if needed. - -- If you provide a host during construction, the client will auto-initialize on first use. -- If no host is provided, the client will anoint a host from the overlay network automatically during initialization. - -You may still **manually call** `await init()` if you want to control when initialization happens. - -Example: - -```ts -const client = new MessageBoxClient({ walletClient, host: process.env.MESSAGEBOX_HOST || 'https://message-box-us-1.bsvb.tech' }) - -// Manual init (optional but supported) -await client.init() - -// Or just start sending — init will auto-run if needed -await client.sendMessage({ recipient, messageBox: 'inbox', body: 'Hello' }) -``` - -Example: - -```ts -const client = new MessageBoxClient({ walletClient, host: process.env.MESSAGEBOX_HOST || 'https://message-box-us-1.bsvb.tech' }) -await client.init() // Must always call init() before using the client -await client.sendMessage({ recipient, messageBox: 'inbox', body: 'Hello' }) -``` -The `init()` method will: - -- Anoint your messagebox host onto the overlay network if necessary. - -- Initialize the client’s internal identity and network information. - -- Ensure that your client can receive messages securely from peers. - ---- - -### 3.2. PeerPayClient Overview - -`PeerPayClient` builds on `MessageBoxClient` to enable **peer-to-peer Bitcoin payments**: - -- **Secure Payment Delivery:** Utilizes the same store-and-forward or live WebSocket approach for delivering payment instructions. -- **Derivation & Signing:** Creates a unique output script for each payment, derived from sender + recipient keys. -- **Live or Delayed:** Works with web sockets for immediate notifications, or HTTP for an asynchronous flow. -- **Wallet Integration:** Accept or reject incoming payments. If accepted, the payment is “internalized” into your [BRC-100](https://github.com/bitcoin-sv/BRCs/blob/master/wallet/0100.md) compatible wallet automatically. - -#### Key Features - -1. **Deterministic derivation** of payment information using the SPV-compliant [BRC-29](https://github.com/bitcoin-sv/BRCs/blob/master/payments/0029.md) protocol. -2. **Secure transaction passing** using the `MessageBoxClient` infrastructure. -3. **Live or offline** support for receiving payments. -4. **Easy acceptance/refunds** with built-in methods. - ---- - -## 4. Quick Start Examples - -Below are two condensed examples: one for basic messaging (MessageBoxClient) and another for peer-to-peer payments (PeerPayClient). - -### 4.1. Using MessageBoxClient - -```js -const { WalletClient } = require('@bsv/sdk') -const { MessageBoxClient } = require('@bsv/message-box-client') - -// Example identity key of the recipient (public key in hex) -const johnSmithKey = '022600d2ef37d123fdcac7d25d7a464ada7acd3fb65a0daf85412140ee20884311' - -async function main() { - // 1) Create your WalletClient (this obtains your identity key) - const myWallet = new WalletClient() - - // 2) Create a MessageBoxClient, pointing to a MessageBoxServer - const msgBoxClient = new MessageBoxClient({ - host: 'https://message-box-us-1.bsvb.tech', - walletClient: myWallet - }) - - // 3) (Optional) Initialize the client manually - await msgBoxClient.init() - - // (Optional) Initialize a WebSocket connection (for real-time listening) - await msgBoxClient.initializeConnection() - - // 4) Send a message to John's "demo_inbox" - await msgBoxClient.sendMessage({ - recipient: johnSmithKey, - messageBox: 'demo_inbox', - body: 'Hello John! This is a test message.' - }) - - // 5) List messages in "demo_inbox" - const messages = await msgBoxClient.listMessages({ messageBox: 'demo_inbox' }) - console.log(messages[0].body) // --> "Hello John! This is a test message." - - // 6) Acknowledge (and delete) them from the server - await msgBoxClient.acknowledgeMessage({ - messageIds: messages.map(msg => msg.messageId.toString()) - }) -} - -main().catch(console.error) -``` -Note: -- If you do not manually call `await init()`, the client will automatically initialize itself when you use it. -- You can still call `await init()` manually if you want explicit control. -- `init()` ensures your identity is properly registered and discoverable via overlay advertisement if necessary. - -**Listening for Live Messages** -If you want push-style message notifications instead of polling: - -```ts -await msgBoxClient.listenForLiveMessages({ - messageBox: 'demo_inbox', - onMessage: (msg) => { - console.log('Received live message in "demo_inbox":', msg.body) - } -}) +npm install @bsv/message-box-client @bsv/sdk ``` ---- +`@bsv/sdk` is a required peer dependency. The package supports Node.js 22 and +newer and publishes module-correct ESM and CommonJS entry points. -### 4.2. Using PeerPayClient +## Basic messaging ```ts +import { MessageBoxClient } from '@bsv/message-box-client' import { WalletClient } from '@bsv/sdk' -import { PeerPayClient } from '@bsv/message-box-client' - -async function paymentDemo() { - // 1) Create your wallet instance - const wallet = new WalletClient() - - // 2) Create a PeerPayClient - const peerPay = new PeerPayClient({ - walletClient: wallet - }) - - // 3) (Optional) Listen for incoming payments - await peerPay.listenForLivePayments({ - onPayment: async (payment) => { - console.log('Received payment:', payment) - // Accept it into the wallet - await peerPay.acceptPayment(payment) - } - }) - - // 4) Send a payment of 50,000 sats to the recipient - await peerPay.sendLivePayment({ - recipient: '0277a2b...e3f4', // recipient's public key - amount: 50000 - }) -} - -paymentDemo().catch(console.error) -``` - -**Note:** `sendLivePayment` will try WebSocket first and fall back to HTTP if unavailable. - ---- - -## 5. API Reference - -```md -### 5.1. MessageBoxClient API - - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) - -##### Interfaces - -| | -| --- | -| [AcknowledgeMessageParams](#interface-acknowledgemessageparams) | -| [EncryptedMessage](#interface-encryptedmessage) | -| [ListMessagesParams](#interface-listmessagesparams) | -| [MessageBoxClientOptions](#interface-messageboxclientoptions) | -| [PeerMessage](#interface-peermessage) | -| [SendMessageParams](#interface-sendmessageparams) | -| [SendMessageResponse](#interface-sendmessageresponse) | - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) - ---- - -###### Interface: AcknowledgeMessageParams -Defines the structure of a request to acknowledge messages. - -Example - -```ts -{ - messageIds: ["abc123", "def456"] -} -``` - -```ts -export interface AcknowledgeMessageParams { - messageIds: string[]; -} -``` - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) - ---- -###### Interface: EncryptedMessage - -Encapsulates an AES-256-GCM encrypted message body. - -Used when transmitting encrypted payloads to the MessageBox server. - -```ts -export interface EncryptedMessage { - encryptedMessage: Base64String; -} -``` - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) - ---- -###### Interface: ListMessagesParams - -Defines the structure of a request to list messages. - -Example - -```ts -{ - messageBox: "payment_inbox" -} -``` - -```ts -export interface ListMessagesParams { - messageBox: string; -} -``` - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) - ---- -###### Interface: MessageBoxClientOptions - -Configuration options for initializing a MessageBoxClient. - -```ts -export interface MessageBoxClientOptions { - walletClient?: WalletClient; - host?: string; - enableLogging?: boolean; - networkPreset?: "local" | "mainnet" | "testnet"; -} -``` - -
- -Interface MessageBoxClientOptions Details - -####### Property enableLogging - -If true, enables detailed logging to the console. - -```ts -enableLogging?: boolean -``` - -####### Property host - -Base URL of the MessageBox server. - -```ts -host?: string -``` - -####### Property networkPreset - -Overlay network preset for routing resolution. - -```ts -networkPreset?: "local" | "mainnet" | "testnet" -``` - -####### Property walletClient - -Wallet instance used for auth, identity, and encryption. -If not provided, a new WalletClient will be created. - -```ts -walletClient?: WalletClient -``` - -
- -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) - ---- -###### Interface: PeerMessage - -Represents a decrypted message received from a MessageBox. -Includes metadata such as sender identity, timestamps, and optional acknowledgment status. - -Used in both HTTP and WebSocket message retrieval responses. - -```ts -export interface PeerMessage { - messageId: string; - body: string; - sender: string; - created_at: string; - updated_at: string; - acknowledged?: boolean; -} -``` - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) - ---- -###### Interface: SendMessageParams - -Parameters required to send a message. -Message content may be a string or object, and encryption is enabled by default. - -Example - -```ts -{ - recipient: "03abc...", - messageBox: "payment_inbox", - body: { type: "ping" }, - skipEncryption: false -} -``` - -```ts -export interface SendMessageParams { - recipient: string; - messageBox: string; - body: string | object; - messageId?: string; - skipEncryption?: boolean; -} -``` - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) - ---- -###### Interface: SendMessageResponse - -Server response structure for successful message delivery. - -Returned by both `sendMessage` and `sendLiveMessage`. - -```ts -export interface SendMessageResponse { - status: string; - messageId: string; -} -``` - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) - ---- -##### Classes - -| | -| --- | -| [Logger](#class-logger) | -| [MessageBoxClient](#class-messageboxclient) | - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) - ---- - -###### Class: Logger - -```ts -export class Logger { - static enable(): void - static disable(): void - static log(...args: unknown[]): void - static warn(...args: unknown[]): void - static error(...args: unknown[]): void -} -``` - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) - ---- -###### Class: MessageBoxClient - -Example - -```ts -const client = new MessageBoxClient({ walletClient, enableLogging: true }) -// Manual init is optional — client will auto-initialize if needed -await client.init() -await client.sendMessage({ recipient, messageBox: 'payment_inbox', body: 'Hello world' }) -``` - -```ts -export class MessageBoxClient { - public readonly authFetch: AuthFetch; - constructor(options: MessageBoxClientOptions = {}) - async init(targetHost: string = this.host): Promise - public getJoinedRooms(): Set - public async getIdentityKey(): Promise - public get testSocket(): ReturnType | undefined - async initializeConnection(): Promise - async resolveHostForRecipient(identityKey: string): Promise - async joinRoom(messageBox: string): Promise - async listenForLiveMessages({ onMessage, messageBox }: { - onMessage: (message: PeerMessage) => void; - messageBox: string; - }): Promise - async sendLiveMessage({ recipient, messageBox, body, messageId, skipEncryption }: SendMessageParams): Promise - async leaveRoom(messageBox: string): Promise - async disconnectWebSocket(): Promise - async sendMessage(message: SendMessageParams, overrideHost?: string): Promise - async anointHost(host: string): Promise<{ - txid: string; - }> - async listMessages({ messageBox }: ListMessagesParams): Promise - async acknowledgeMessage({ messageIds }: AcknowledgeMessageParams): Promise -} -``` - -See also: [AcknowledgeMessageParams](#interface-acknowledgemessageparams), [ListMessagesParams](#interface-listmessagesparams), [MessageBoxClientOptions](#interface-messageboxclientoptions), [PeerMessage](#interface-peermessage), [SendMessageParams](#interface-sendmessageparams), [SendMessageResponse](#interface-sendmessageresponse) - -
- -Class MessageBoxClient Details - -####### Constructor - -```ts -constructor(options: MessageBoxClientOptions = {}) -``` -See also: [MessageBoxClientOptions](#interface-messageboxclientoptions) - -Argument Details - -+ **options** - + Initialization options for the MessageBoxClient. - -Example - -```ts -const client = new MessageBoxClient({ - host: 'https://messagebox.example', - walletClient, - enableLogging: true, - networkPreset: 'testnet' +const wallet = new WalletClient() +const messages = new MessageBoxClient({ + walletClient: wallet, + host: 'https://message-box-us-1.bsvb.tech' }) -await client.init() -``` - -####### Method acknowledgeMessage - -```ts -async acknowledgeMessage({ messageIds }: AcknowledgeMessageParams): Promise -``` -See also: [AcknowledgeMessageParams](#interface-acknowledgemessageparams) - -Returns - -- A string indicating the result, typically `'success'`. - -Argument Details - -+ **params** - + An object containing an array of message IDs to acknowledge. - -Throws - -If the message ID array is missing or empty, or if the request to the server fails. - -Example - -```ts -await client.acknowledgeMessage({ messageIds: ['msg123', 'msg456'] }) -``` - -####### Method anointHost - -```ts -async anointHost(host: string): Promise<{ - txid: string; -}> -``` - -Returns - -- The transaction ID of the advertisement broadcast to the overlay network. - -Argument Details - -+ **host** - + The full URL of the server you want to designate as your MessageBox host (e.g., "https://mybox.com"). - -Throws - -If the URL is invalid, the PushDrop creation fails, or the overlay broadcast does not succeed. - -Example - -```ts -const { txid } = await client.anointHost('https://my-messagebox.io') -``` - -####### Method disconnectWebSocket - -```ts -async disconnectWebSocket(): Promise -``` - -Returns - -Resolves when the WebSocket connection is successfully closed. - -Example - -```ts -await client.disconnectWebSocket() -``` - -####### Method getIdentityKey - -```ts -public async getIdentityKey(): Promise -``` - -Returns - -The identity public key of the user - -####### Method getJoinedRooms - -```ts -public getJoinedRooms(): Set -``` - -Returns - -A set of currently joined WebSocket room IDs - -####### Method init - -```ts -async init(targetHost: string = this.host): Promise -``` - -Argument Details - -+ **targetHost** - + Optional host to set or override the default host. - -Throws - -If no valid host is provided, or anointing fails. - -Example - -```ts -const client = new MessageBoxClient({ host: 'https://mybox.example', walletClient }) -await client.init() -await client.sendMessage({ recipient, messageBox: 'inbox', body: 'Hello' }) -``` - -####### Method initializeConnection - -```ts -async initializeConnection(): Promise -``` - -Throws - -If the identity key is unavailable or authentication fails - -Example - -```ts -const mb = new MessageBoxClient({ walletClient }) -await mb.initializeConnection() -// WebSocket is now ready for use -``` - -####### Method joinRoom - -```ts -async joinRoom(messageBox: string): Promise -``` - -Argument Details - -+ **messageBox** - + The name of the WebSocket room to join (e.g., "payment_inbox"). - -Example - -```ts -await client.joinRoom('payment_inbox') -// Now listening for real-time messages in room '028d...-payment_inbox' -``` - -####### Method leaveRoom - -```ts -async leaveRoom(messageBox: string): Promise -``` - -Argument Details - -+ **messageBox** - + The name of the WebSocket room to leave (e.g., `payment_inbox`). - -Example - -```ts -await client.leaveRoom('payment_inbox') -``` - -####### Method listMessages - -```ts -async listMessages({ messageBox }: ListMessagesParams): Promise -``` -See also: [ListMessagesParams](#interface-listmessagesparams), [PeerMessage](#interface-peermessage) - -Returns - -- Returns an array of decrypted `PeerMessage` objects. - -Argument Details - -+ **params** - + Contains the name of the messageBox to read from. -Throws +const recipient = '025706528f0f6894b2ba505007267ccff1133e004452a1f6b72ac716f246216366' -If no messageBox is specified, the request fails, or the server returns an error. +await messages.sendMessage({ + recipient, + messageBox: 'general_inbox', + body: { text: 'Hello' } +}) -Example +const pending = await messages.listMessages({ + messageBox: 'general_inbox' +}) -```ts -const messages = await client.listMessages({ messageBox: 'inbox' }) -messages.forEach(msg => console.log(msg.sender, msg.body)) +await messages.acknowledgeMessage({ + messageIds: pending.map(message => message.messageId) +}) ``` -####### Method listenForLiveMessages +Explicit `init()` is optional. Public methods initialize the wallet identity +when needed: ```ts -async listenForLiveMessages({ onMessage, messageBox }: { - onMessage: (message: PeerMessage) => void; - messageBox: string; -}): Promise +await messages.init() ``` -See also: [PeerMessage](#interface-peermessage) - -Argument Details - -+ **params** - + Configuration for the live message listener. -Example +Encryption is enabled by default through the wallet protocol. Plaintext is an +explicit interoperability choice: ```ts -await client.listenForLiveMessages({ - messageBox: 'payment_inbox', - onMessage: (msg) => console.log('Received live message:', msg) +await messages.sendMessage({ + recipient, + messageBox: 'public_payloads', + body: 'This payload is intentionally plaintext.', + skipEncryption: true }) ``` -####### Method resolveHostForRecipient +The server authenticates and routes ciphertext but does not hold the wallet +keys required to decrypt it. -```ts -async resolveHostForRecipient(identityKey: string): Promise -``` - -Returns - -- A fully qualified host URL for the recipient's MessageBox server. - -Argument Details - -+ **identityKey** - + The public identity key of the intended recipient. - -Example +## Live messages ```ts -const host = await resolveHostForRecipient('028d...') // → returns either overlay host or this.host -``` - -####### Method sendLiveMessage +await messages.listenForLiveMessages({ + messageBox: 'general_inbox', + onMessage: message => { + console.log(message.sender, message.body) + } +}) -```ts -async sendLiveMessage({ recipient, messageBox, body, messageId, skipEncryption }: SendMessageParams): Promise +await messages.sendLiveMessage({ + recipient, + messageBox: 'general_inbox', + body: 'Live when possible, store-and-forward when needed.' +}) ``` -See also: [SendMessageParams](#interface-sendmessageparams), [SendMessageResponse](#interface-sendmessageresponse) - -Returns - -A success response with the generated messageId. -Argument Details +`sendLiveMessage()` uses an authenticated WebSocket and falls back to the HTTP +send route when the socket is unavailable or does not acknowledge delivery. +Call `disconnectWebSocket()` when a long-lived client shuts down. -+ **param0** - + The message parameters including recipient, box name, body, and options. +## Host selection and public-service access -Throws - -If message validation fails, HMAC generation fails, or both WebSocket and HTTP fail to deliver. - -Example +An explicitly configured host may use HTTP or HTTPS so local development and +operator-controlled private networks remain possible. It must be an absolute +URL without credentials, a query, or a fragment. Route prefixes are supported: ```ts -await client.sendLiveMessage({ - recipient: '028d...', - messageBox: 'payment_inbox', - body: { amount: 1000 } +const messages = new MessageBoxClient({ + walletClient: wallet, + host: 'https://messaging.example.com/api' }) ``` -####### Method sendMessage - -```ts -async sendMessage(message: SendMessageParams, overrideHost?: string): Promise -``` -See also: [SendMessageParams](#interface-sendmessageparams), [SendMessageResponse](#interface-sendmessageresponse) - -Returns - -- Resolves with `{ status, messageId }` on success. - -Argument Details - -+ **message** - + Contains recipient, messageBox name, message body, optional messageId, and skipEncryption flag. -+ **overrideHost** - + Optional host to override overlay resolution (useful for testing or private routing). +Overlay advertisements are untrusted network input. Discovered destinations +must use public HTTPS addresses; loopback, private, link-local, reserved, and +documentation-only hosts are rejected before a request is made. If no valid +advertisement exists, the configured host is used. -Throws +Message Box is a public protocol service. Compatible servers should remain +browser-accessible by default with credential-free wildcard CORS, including +opaque `Origin: null` callers such as mobile webviews. Operators may opt into +an exact-origin allowlist or disable browser CORS. CSP governs served documents +and is not API authorization. BRC-103 authentication, recipient ownership, +permissions, payments, quotas, and message encryption remain the security +boundaries. -If validation, encryption, HMAC, or network request fails. - -Example +## Permissions and delivery quotes ```ts -await client.sendMessage({ - recipient: '03abc...', +await messages.setMessageBoxPermission({ messageBox: 'notifications', - body: { type: 'ping' } + sender: recipient, + recipientFee: 10 }) -``` -
+const quote = await messages.getMessageBoxQuote({ + recipient, + messageBox: 'notifications' +}) +``` -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) +Permission fees use these values: ---- +- `-1`: blocked +- `0`: allowed without a recipient fee +- positive integer: required satoshi amount - ---- +Set `checkPermissions: true` on `sendMessage()` when the client should quote +and construct the required payment before sending. -### 5.2. PeerPayClient API +## Peer payments ```ts import { PeerPayClient } from '@bsv/message-box-client' -``` -#### Constructor +const payments = new PeerPayClient({ walletClient: wallet }) -```ts -new PeerPayClient({ - walletClient: WalletClient, - messageBoxHost?: string, - enableLogging?: boolean +await payments.sendPayment({ + recipient, + amount: 50_000 }) -``` - -- **walletClient**: (Required) Your identity/signing wallet. -- **messageBoxHost**: (Optional) Base URL of the MessageBoxServer. Defaults to `https://message-box-us-1.bsvb.tech`. -- **enableLogging**: (Optional) Enables verbose debug output. - ---- -#### `sendPayment({ recipient, amount })` - -```ts -await peerPay.sendPayment({ - recipient: '0277a2b...', - amount: 10000 -}) +const incoming = await payments.listIncomingPayments() +for (const payment of incoming) { + await payments.acceptPayment(payment) +} ``` -- Sends a payment using HTTP. -- Internally derives a public key for the recipient and builds a transaction. +`PeerPayClient` uses BRC-29 wallet-payment derivation and the same authenticated +Message Box transport. It also supports live delivery, payment requests, +responses, cancellations, and explicit rejection/refund flows. ---- +## Token settlement -#### `sendLivePayment({ recipient, amount })` +`PeerTokenClient` routes token transfers and requests through Message Box while +delegating token-standard-specific transaction work to registered +`TokenSettlementAdapter` implementations: ```ts -await peerPay.sendLivePayment({ - recipient: '0277a2b...', - amount: 15000 -}) -``` - -- Sends a payment using WebSockets, falling back to HTTP if needed. - ---- - -#### `listenForLivePayments({ onPayment })` +import { PeerTokenClient } from '@bsv/message-box-client' -```ts -await peerPay.listenForLivePayments({ - onPayment: (payment) => { - console.log('New live payment:', payment) - } +const tokens = new PeerTokenClient({ + walletClient: wallet, + adapters: [myTokenAdapter] }) ``` -- Subscribes to live payments in the `payment_inbox`. -- Invokes `onPayment` callback with an `IncomingPayment` object: +The library does not assume a token wire format. Adapters own construction, +acceptance, termination, and receipt data for their protocol. -```ts -interface IncomingPayment { - messageId: number; - sender: string; - token: { - customInstructions: { - derivationPrefix: string; - derivationSuffix: string; - }; - transaction: any; // typically your BSV transaction format - amount: number; - }; -} -``` +`RemittanceAdapter` exposes Message Box as an SDK remittance communication +layer. ---- +## HTTP and WebSocket contract -#### `acceptPayment(payment)` +The client uses these authenticated HTTP routes: -```ts -await peerPay.acceptPayment(payment) -``` - -- Accepts (and "internalizes") the payment into your wallet. -- Acknowledges the message, removing it from the inbox. - ---- +| Method | Route | Purpose | +| ------ | --------------------- | ---------------------------------------------------- | +| POST | `/sendMessage` | Send to one or up to 100 recipients | +| POST | `/listMessages` | Read a named box owned by the authenticated identity | +| POST | `/acknowledgeMessage` | Delete acknowledged messages owned by the identity | +| POST | `/registerDevice` | Register a Firebase push token | +| GET | `/devices` | List the identity's registered devices | +| POST | `/permissions/set` | Set a sender-specific or box-wide permission | +| GET | `/permissions/get` | Read a permission | +| GET | `/permissions/list` | List permissions | +| GET | `/permissions/quote` | Quote one or up to 100 recipients | -#### `rejectPayment(payment)` +The corresponding OpenAPI source is +[`specs/messaging/message-box-http.yaml`](../../../specs/messaging/message-box-http.yaml). +Live delivery uses authenticated Socket.IO events and identity-owned rooms. -```ts -await peerPay.rejectPayment(payment) -``` +## Public exports -- Rejects the payment, returning a **refund** to the sender (minus a small fee, e.g. 1000 sats). -- If the amount is too small to refund, the payment is simply acknowledged and dropped. +- `MessageBoxClient` +- `PeerPayClient` +- `PeerTokenClient` +- `RemittanceAdapter` +- `TokenSettlementAdapter` and its supporting types +- messaging, payment, token, device, permission, quote, and batch-send types +- standard message-box name constants ---- +## Development -#### `listIncomingPayments()` +From the repository root: -```ts -const payments = await peerPay.listIncomingPayments() +```bash +pnpm --filter @bsv/message-box-client typecheck +pnpm --filter @bsv/message-box-client lint +pnpm --filter @bsv/message-box-client test +pnpm --filter @bsv/message-box-client test:coverage +pnpm --filter @bsv/message-box-client pack:check +pnpm --filter @bsv/message-box-client test:browser ``` -- Lists all incoming payments in the `payment_inbox`. -- Returns an array of `IncomingPayment` objects. - ---- - -## 6. Contributing +The unit suite is deterministic and does not contact a deployed service. +`test:integration` is explicitly opt-in because it requires configured wallet, +Message Box, database, WebSocket, and overlay services: -1. Clone this repository. -2. Install dependencies with `npm install`. -3. Make your changes, write tests, and open a PR. +```bash +MESSAGE_BOX_RUN_INTEGRATION=true \ +MESSAGE_BOX_INTEGRATION_HOST=http://127.0.0.1:8080 \ +MESSAGE_BOX_WALLET_ORIGINATOR=localhost \ +pnpm --filter @bsv/message-box-client test:integration +``` -We welcome bug reports, feature requests, and community contributions! +Targets under `*.bsvb.tech` require an additional explicit +`MESSAGE_BOX_ALLOW_PRODUCTION_INTEGRATION=true` acknowledgement because the +suite creates and acknowledges real messages. ---- +The release tarball contains compiled ESM/CommonJS JavaScript, matching +declarations and source maps, the UMD browser bundle, this README, and the +license. It does not publish TypeScript source, tests, coverage, editor files, +or package-manager locks. -## 7. License +## License -This code is licensed under the [Open BSV License Version 6](./LICENSE.txt). +See [LICENSE.txt](./LICENSE.txt). diff --git a/packages/messaging/message-box-client/browser-budget.json b/packages/messaging/message-box-client/browser-budget.json new file mode 100644 index 000000000..60c4d133c --- /dev/null +++ b/packages/messaging/message-box-client/browser-budget.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": 1, + "profile": "browser", + "package": "@bsv/message-box-client", + "entry": ".", + "requiredExports": [ + "DEFAULT_PAYMENT_REQUEST_MAX_AMOUNT", + "DEFAULT_PAYMENT_REQUEST_MIN_AMOUNT", + "MessageBoxClient", + "PAYMENT_REQUESTS_MESSAGEBOX", + "PAYMENT_REQUEST_RESPONSES_MESSAGEBOX", + "PeerPayClient", + "PeerTokenClient", + "RemittanceAdapter", + "STANDARD_PAYMENT_MESSAGEBOX", + "STANDARD_TOKEN_MESSAGEBOX", + "TOKEN_REQUESTS_MESSAGEBOX", + "TOKEN_REQUEST_RESPONSES_MESSAGEBOX" + ], + "prohibitedExports": [], + "maximumBytes": { + "vite": { + "raw": 1700000, + "gzip": 410000, + "brotli": 345000 + }, + "esbuild": { + "raw": 1400000, + "gzip": 380000, + "brotli": 325000 + } + }, + "umd": { + "path": "dist/umd/bundle.js", + "global": "messageBoxClient", + "maximumBytes": { + "raw": 490000, + "gzip": 140000, + "brotli": 117000 + } + } +} diff --git a/packages/messaging/message-box-client/jest.config.integration.ts b/packages/messaging/message-box-client/jest.config.integration.ts index 67e11f745..343610be1 100644 --- a/packages/messaging/message-box-client/jest.config.integration.ts +++ b/packages/messaging/message-box-client/jest.config.integration.ts @@ -6,13 +6,16 @@ const config: JestConfigWithTsJest = { moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], modulePathIgnorePatterns: ['/dist/'], transform: { - '^.+\\.ts?$': ['ts-jest', { - tsconfig: { - module: 'commonjs', - moduleResolution: 'bundler', - isolatedModules: true + '^.+\\.ts?$': [ + 'ts-jest', + { + tsconfig: { + module: 'commonjs', + moduleResolution: 'bundler', + isolatedModules: true + } } - }] + ] }, moduleNameMapper: { // Ensure Jest resolves TypeScript files without ".js" errors diff --git a/packages/messaging/message-box-client/jest.config.ts b/packages/messaging/message-box-client/jest.config.ts index e13f7c967..a9ff91f3a 100644 --- a/packages/messaging/message-box-client/jest.config.ts +++ b/packages/messaging/message-box-client/jest.config.ts @@ -9,25 +9,24 @@ const config: JestConfigWithTsJest = { testEnvironment: 'node', // Ignore compiled output - testPathIgnorePatterns: [ - 'dist/', - '/node_modules/', - './src/__tests/integration/' - ], + testPathIgnorePatterns: ['dist/', '/node_modules/', './src/__tests/integration/'], modulePathIgnorePatterns: ['/dist/'], // Keep ts-jest options in transform to avoid deprecated globals config transform: { - '^.+\\.ts$': ['ts-jest', { - useESM: true, - tsconfig: { - module: 'NodeNext', - moduleResolution: 'NodeNext', - esModuleInterop: true, - isolatedModules: true, - strictNullChecks: true + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + module: 'NodeNext', + moduleResolution: 'NodeNext', + esModuleInterop: true, + isolatedModules: true, + strictNullChecks: true + } } - }] + ] }, // Tell Jest that files ending in .ts should be treated as ESM modules diff --git a/packages/messaging/message-box-client/mod.ts b/packages/messaging/message-box-client/mod.ts index 6dc399e3a..ec287850b 100644 --- a/packages/messaging/message-box-client/mod.ts +++ b/packages/messaging/message-box-client/mod.ts @@ -3,4 +3,5 @@ export * from './src/PeerPayClient.js' export * from './src/PeerTokenClient.js' export * from './src/TokenSettlementAdapter.js' export * from './src/types.js' +export * from './src/types/permissions.js' export * from './src/RemittanceAdapter.js' diff --git a/packages/messaging/message-box-client/package.json b/packages/messaging/message-box-client/package.json index cdc0f0fc4..68e25ca4c 100644 --- a/packages/messaging/message-box-client/package.json +++ b/packages/messaging/message-box-client/package.json @@ -19,25 +19,30 @@ }, "description": "A client for P2P messaging and payments", "type": "module", - "main": "dist/cjs/mod.js", - "module": "dist/esm/mod.js", - "types": "dist/types/mod.d.ts", + "main": "./dist/mod.cjs", + "module": "./dist/mod.js", + "types": "./dist/mod.d.ts", "files": [ "dist", - "src", - "mod.ts", + "README.md", "LICENSE.txt" ], "scripts": { - "build": "npm run build:ts && npm run build:umd", - "build:ts": "tsc -b && tsconfig-to-dual-package tsconfig.cjs.json", + "build": "pnpm build:ts && pnpm build:umd", + "build:ts": "tsdown", "build:umd": "webpack --config webpack.config.js", + "clean": "rimraf dist", + "dev": "tsdown --watch", + "format:check": "pnpm --workspace-root exec prettier --check \"packages/messaging/message-box-client/**/*.{cjs,js,json,mjs,ts}\"", + "lint": "oxlint mod.ts scripts src --deny-warnings", + "pack:check": "pnpm build && node ../../../scripts/check-package-artifact.mjs . --exports MessageBoxClient,PeerPayClient,PeerTokenClient,RemittanceAdapter,STANDARD_PAYMENT_MESSAGEBOX,PAYMENT_REQUESTS_MESSAGEBOX,PAYMENT_REQUEST_RESPONSES_MESSAGEBOX,STANDARD_TOKEN_MESSAGEBOX,TOKEN_REQUESTS_MESSAGEBOX,TOKEN_REQUEST_RESPONSES_MESSAGEBOX,DEFAULT_PAYMENT_REQUEST_MIN_AMOUNT,DEFAULT_PAYMENT_REQUEST_MAX_AMOUNT", + "prepublishOnly": "pnpm build", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config=jest.config.ts --watchman=false", - "test:integration": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config=jest.config.integration.ts --watchman=false", - "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config=jest.config.ts --watchman=false --coverage --coverageReporters=text --coverageReporters=html --coverageReporters=lcov", + "test:browser": "pnpm build && node ../../../scripts/check-browser-package.mjs .", + "test:integration": "node scripts/run-integration.mjs", + "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config=jest.config.ts --watchman=false --coverage", "test:watch": "jest --watch", - "lint": "oxlint src", - "clean": "rimraf dist node_modules && npm install" + "typecheck": "tsc --project tsconfig.typecheck.json" }, "keywords": [ "BSV", @@ -50,34 +55,31 @@ ], "exports": { ".": { - "import": "./dist/esm/mod.js", - "require": "./dist/cjs/mod.js", - "types": "./dist/types/mod.d.ts" + "import": { + "types": "./dist/mod.d.ts", + "default": "./dist/mod.js" + }, + "require": { + "types": "./dist/mod.d.cts", + "default": "./dist/mod.cjs" + } }, "./package.json": "./package.json" }, "author": "BSV Blockchain Association", "license": "SEE LICENSE IN LICENSE.txt", "devDependencies": { - "@bsv/auth-express-middleware": "workspace:^", - "@bsv/payment-express-middleware": "workspace:^", "@bsv/sdk": "workspace:^", "@types/jest": "^30.0.0", "@types/node": "^26.1.1", - "@types/supertest": "^7.2.1", - "ejs": "^6.0.1", "jest": "^30.4.2", - "jest-fetch-mock": "^4.2.0", + "oxlint": "^1.75.0", + "rimraf": "^6.1.3", "ts-jest": "^29.4.12", - "ts-node": "^10.9.2", - "ts2md": "^0.2.8", - "tsconfig-to-dual-package": "^1.2.0", + "tsdown": "0.22.14", "typescript": "^6.0.3", "webpack": "^5.109.0", - "webpack-bundle-analyzer": "^5.3.1", - "webpack-cli": "^7.2.1", - "webpack-merge": "^6.0.1", - "oxlint": "^1.75.0" + "webpack-cli": "^7.2.1" }, "dependencies": { "@bsv/authsocket-client": "workspace:^" diff --git a/packages/messaging/message-box-client/scripts/remove-node-https-fallback.cjs b/packages/messaging/message-box-client/scripts/remove-node-https-fallback.cjs new file mode 100644 index 000000000..57b94a853 --- /dev/null +++ b/packages/messaging/message-box-client/scripts/remove-node-https-fallback.cjs @@ -0,0 +1,15 @@ +'use strict' + +/** + * The SDK's browser-safe default HTTP client includes a guarded CommonJS + * `require("node:https")` fallback for old Node runtimes. Webpack leaves that + * unreachable string in UMD output, where the browser composition gate must + * reject it. Browser clients use fetch, so remove only this exact fallback. + */ +module.exports = function removeNodeHttpsFallback(source) { + const transformed = source.replace(/require\((['"])node:https\1\)/g, 'undefined') + if (transformed === source) { + throw new Error('Expected the SDK node:https fallback in the browser build input.') + } + return transformed +} diff --git a/packages/messaging/message-box-client/scripts/run-integration.mjs b/packages/messaging/message-box-client/scripts/run-integration.mjs new file mode 100644 index 000000000..324040f89 --- /dev/null +++ b/packages/messaging/message-box-client/scripts/run-integration.mjs @@ -0,0 +1,54 @@ +import { spawn } from 'node:child_process' +import process from 'node:process' + +function requiredEnvironment(name) { + const value = process.env[name]?.trim() + if (value == null || value === '') { + throw new Error(`${name} is required for the opt-in Message Box integration suite.`) + } + return value +} + +if (process.env.MESSAGE_BOX_RUN_INTEGRATION !== 'true') { + throw new Error( + 'Live Message Box integration tests are opt-in. Set MESSAGE_BOX_RUN_INTEGRATION=true, ' + + 'MESSAGE_BOX_INTEGRATION_HOST, and MESSAGE_BOX_WALLET_ORIGINATOR explicitly.' + ) +} + +const host = new URL(requiredEnvironment('MESSAGE_BOX_INTEGRATION_HOST')) +requiredEnvironment('MESSAGE_BOX_WALLET_ORIGINATOR') +if (!['http:', 'https:'].includes(host.protocol)) { + throw new Error('MESSAGE_BOX_INTEGRATION_HOST must use HTTP or HTTPS.') +} +if ( + host.hostname.endsWith('.bsvb.tech') && + process.env.MESSAGE_BOX_ALLOW_PRODUCTION_INTEGRATION !== 'true' +) { + throw new Error( + 'Production integration targets require MESSAGE_BOX_ALLOW_PRODUCTION_INTEGRATION=true.' + ) +} + +const child = spawn( + process.execPath, + [ + '--experimental-vm-modules', + 'node_modules/jest/bin/jest.js', + '--config=jest.config.integration.ts', + '--watchman=false', + '--runInBand' + ], + { stdio: 'inherit', env: process.env } +) + +child.once('error', error => { + throw error +}) +child.once('exit', (code, signal) => { + if (signal != null) { + process.kill(process.pid, signal) + return + } + process.exitCode = code ?? 1 +}) diff --git a/packages/messaging/message-box-client/src/MessageBoxClient.ts b/packages/messaging/message-box-client/src/MessageBoxClient.ts index 807ded7cc..e63642f19 100644 --- a/packages/messaging/message-box-client/src/MessageBoxClient.ts +++ b/packages/messaging/message-box-client/src/MessageBoxClient.ts @@ -39,11 +39,30 @@ import { InternalizeOutput, Random, OriginatorDomainNameStringUnder250Bytes, - Beef, + Beef } from '@bsv/sdk' import { AuthSocketClient } from '@bsv/authsocket-client' import * as Logger from './Utils/logger.js' -import { AcknowledgeMessageParams, AdvertisementToken, EncryptedMessage, ListMessagesParams, MessageBoxClientOptions, Payment, PeerMessage, SendMessageParams, SendMessageResponse, DeviceRegistrationParams, DeviceRegistrationResponse, RegisteredDevice, ListDevicesResponse } from './types.js' +import { + messageBoxEndpoint, + normalizeMessageBoxHost, + normalizeOverlayMessageBoxHost +} from './host.js' +import { + AcknowledgeMessageParams, + AdvertisementToken, + EncryptedMessage, + ListMessagesParams, + MessageBoxClientOptions, + Payment, + PeerMessage, + SendMessageParams, + SendMessageResponse, + DeviceRegistrationParams, + DeviceRegistrationResponse, + RegisteredDevice, + ListDevicesResponse +} from './types.js' import { SetMessageBoxPermissionParams, GetMessageBoxPermissionParams, @@ -60,11 +79,11 @@ const DEFAULT_MAINNET_HOST = 'https://message-box-us-1.bsvb.tech' const DEFAULT_TESTNET_HOST = DEFAULT_MAINNET_HOST /** Build status + description for a batch send result. */ -function buildBatchSendResult ( +function buildBatchSendResult( sentCount: number, allowedCount: number, blockedCount: number -): { status: SendListResult['status'], description: string } { +): { status: SendListResult['status']; description: string } { if (sentCount === allowedCount) { return { status: 'success', description: `Sent to ${sentCount} recipients.` } } @@ -164,12 +183,9 @@ export class MessageBoxClient { originator = undefined } = options - const defaultHost = - networkPreset === 'testnet' - ? DEFAULT_TESTNET_HOST - : DEFAULT_MAINNET_HOST + const defaultHost = networkPreset === 'testnet' ? DEFAULT_TESTNET_HOST : DEFAULT_MAINNET_HOST - this.host = host?.trim() ?? defaultHost + this.host = normalizeMessageBoxHost(host ?? defaultHost) this.originator = originator this.walletClient = walletClient ?? new WalletClient('auto', originator) this.authFetch = new AuthFetch(this.walletClient, undefined, undefined, originator) @@ -209,9 +225,13 @@ export class MessageBoxClient { * await client.sendMessage({ recipient, messageBox: 'inbox', body: 'Hello' }) */ async init(targetHost: string = this.host): Promise { - const normalizedHost = targetHost?.trim() - if (normalizedHost === '') { - throw new Error('Cannot initialize: No valid host provided') + let normalizedHost: string + try { + normalizedHost = normalizeMessageBoxHost(targetHost) + } catch (error) { + throw new Error( + `Cannot initialize: ${error instanceof Error ? error.message : 'No valid host provided'}` + ) } // Check if this is an override host @@ -256,13 +276,13 @@ export class MessageBoxClient { } /** - * @method getIdentityKey - * @param {string} [originator] - Optional originator to use for identity key lookup - * @returns {Promise} The identity public key of the user - * @description - * Returns the client's identity key, used for signing, encryption, and addressing. - * If not already loaded, it will fetch and cache it. - */ + * @method getIdentityKey + * @param {string} [originator] - Optional originator to use for identity key lookup + * @returns {Promise} The identity public key of the user + * @description + * Returns the client's identity key, used for signing, encryption, and addressing. + * If not already loaded, it will fetch and cache it. + */ public async getIdentityKey(): Promise { if (this.myIdentityKey != null && this.myIdentityKey.trim() !== '') { return this.myIdentityKey @@ -343,11 +363,11 @@ export class MessageBoxClient { } if (this.socket == null) { - const targetHost = overrideHost ?? this.host - if (typeof targetHost !== 'string' || targetHost.trim() === '') { - throw new Error('Cannot initialize WebSocket: No valid host provided') - } - this.socket = AuthSocketClient(targetHost, { wallet: this.walletClient, originator: this.originator }) + const targetHost = normalizeMessageBoxHost(overrideHost ?? this.host) + this.socket = AuthSocketClient(targetHost, { + wallet: this.walletClient, + originator: this.originator + }) this.socket.on('connect', () => { Logger.log('[MB CLIENT] Connected to WebSocket.') @@ -361,13 +381,13 @@ export class MessageBoxClient { }) // Listen for authentication success from the server - this.socket.on('authenticationSuccess', (data) => { + this.socket.on('authenticationSuccess', data => { Logger.log(`[MB CLIENT] WebSocket authentication successful: ${JSON.stringify(data)}`) this.socketAuthenticated = true }) // Handle authentication failures - this.socket.on('authenticationFailed', (data) => { + this.socket.on('authenticationFailed', data => { Logger.error(`[MB CLIENT ERROR] WebSocket authentication failed: ${JSON.stringify(data)}`) this.socketAuthenticated = false }) @@ -378,7 +398,7 @@ export class MessageBoxClient { this.socketAuthenticated = false }) - this.socket.on('error', (error) => { + this.socket.on('error', error => { Logger.error('[MB CLIENT ERROR] WebSocket error:', error) }) } @@ -482,7 +502,9 @@ export class MessageBoxClient { async resolveHostForRecipient(identityKey: string): Promise { const advertisementTokens = await this.queryAdvertisements(identityKey) if (advertisementTokens.length === 0) { - Logger.warn(`[MB CLIENT] No advertisements for ${identityKey}, using default host ${this.host}`) + Logger.warn( + `[MB CLIENT] No advertisements for ${identityKey}, using default host ${this.host}` + ) return this.host } // Return the first host found @@ -497,13 +519,12 @@ export class MessageBoxClient { * @param host? if passed, only look for adverts anointed at that host * @returns 0-length array if nothing valid was found */ - async queryAdvertisements( - identityKey?: string, - host?: string - ): Promise { + async queryAdvertisements(identityKey?: string, host?: string): Promise { const hosts: AdvertisementToken[] = [] try { - const query: Record = { identityKey: identityKey ?? await this.getIdentityKey() } + const query: Record = { + identityKey: identityKey ?? (await this.getIdentityKey()) + } if (host != null && host.trim() !== '') query.host = host const result = await this.lookupResolver.query({ @@ -539,28 +560,9 @@ export class MessageBoxClient { } catch (err) { Logger.error('[MB CLIENT ERROR] _queryAdvertisements failed:', err) } - return hosts.filter(item => { - const h = item.host.trim().toLowerCase() - if (!h) return false - try { - const url = new URL(h) - if (url.protocol !== 'https:') return false - const hostname = url.hostname - if ( - hostname === 'localhost' || - hostname === '127.0.0.1' || - hostname === '0.0.0.0' || - hostname === '::1' || - hostname.endsWith('.local') || - hostname.endsWith('.example.com') || - hostname.endsWith('.test') || - hostname.endsWith('.invalid') || - hostname.endsWith('.localhost') - ) return false - return true - } catch { - return false - } + return hosts.flatMap(item => { + const normalizedHost = normalizeOverlayMessageBoxHost(item.host) + return normalizedHost == null ? [] : [{ ...item, host: normalizedHost }] }) } @@ -659,7 +661,7 @@ export class MessageBoxClient { } // Join the room - await this.joinRoom(messageBox, this.originator) + await this.joinRoom(messageBox, overrideHost) // Ensure identity key is available before creating roomId if (this.myIdentityKey == null || this.myIdentityKey.trim() === '') { @@ -691,19 +693,29 @@ export class MessageBoxClient { typeof (parsedBody as any).encryptedMessage === 'string' ) { Logger.log(`[MB CLIENT] Decrypting message from ${String(message.sender)}...`) - const decrypted = await this.walletClient.decrypt({ - protocolID: [1, 'messagebox'], - keyID: '1', - counterparty: message.sender, - ciphertext: Utils.toArray((parsedBody as any).encryptedMessage, 'base64') - }, this.originator) + const decrypted = await this.walletClient.decrypt( + { + protocolID: [1, 'messagebox'], + keyID: '1', + counterparty: message.sender, + ciphertext: Utils.toArray((parsedBody as any).encryptedMessage, 'base64') + }, + this.originator + ) message.body = Utils.toUTF8(decrypted.plaintext) } else { Logger.log('[MB CLIENT] Message is not encrypted.') - message.body = typeof parsedBody === 'string' - ? parsedBody - : (() => { try { return JSON.stringify(parsedBody) } catch { return '[Error: Unstringifiable message]' } })() + message.body = + typeof parsedBody === 'string' + ? parsedBody + : (() => { + try { + return JSON.stringify(parsedBody) + } catch { + return '[Error: Unstringifiable message]' + } + })() } } catch (err) { Logger.error('[MB CLIENT ERROR] Failed to parse or decrypt live message:', err) @@ -744,14 +756,10 @@ export class MessageBoxClient { * body: { amount: 1000 } * }) */ - async sendLiveMessage({ - recipient, - messageBox, - body, - messageId, - skipEncryption, - checkPermissions - }: SendMessageParams, overrideHost?: string): Promise { + async sendLiveMessage( + { recipient, messageBox, body, messageId, skipEncryption, checkPermissions }: SendMessageParams, + overrideHost?: string + ): Promise { if (recipient == null || recipient.trim() === '') { throw new Error('[MB CLIENT ERROR] Recipient identity key is required') } @@ -763,23 +771,33 @@ export class MessageBoxClient { } // Ensure room is joined before sending - await this.joinRoom(messageBox, this.originator) + await this.joinRoom(messageBox, overrideHost) // Fallback to HTTP if WebSocket is not connected if (this.socket == null || !this.socket.connected) { Logger.warn('[MB CLIENT WARNING] WebSocket not connected, falling back to HTTP') - return await this.sendMessage({ recipient, messageBox, body, messageId, skipEncryption, checkPermissions }, overrideHost) + return await this.sendMessage( + { recipient, messageBox, body, messageId, skipEncryption, checkPermissions }, + overrideHost + ) } let finalMessageId: string try { - const hmac = await this.walletClient.createHmac({ - data: Array.from(new TextEncoder().encode(JSON.stringify(body))), - protocolID: [1, 'messagebox'], - keyID: '1', - counterparty: recipient - }, this.originator) - finalMessageId = messageId ?? Array.from(hmac.hmac).map(b => b.toString(16).padStart(2, '0')).join('') + const hmac = await this.walletClient.createHmac( + { + data: Array.from(new TextEncoder().encode(JSON.stringify(body))), + protocolID: [1, 'messagebox'], + keyID: '1', + counterparty: recipient + }, + this.originator + ) + finalMessageId = + messageId ?? + Array.from(hmac.hmac) + .map(b => b.toString(16).padStart(2, '0')) + .join('') } catch (error) { Logger.error('[MB CLIENT ERROR] Failed to generate HMAC:', error) throw new Error('Failed to generate message identifier.') @@ -792,12 +810,15 @@ export class MessageBoxClient { if (skipEncryption === true) { outgoingBody = typeof body === 'string' ? body : JSON.stringify(body) } else { - const encryptedMessage = await this.walletClient.encrypt({ - protocolID: [1, 'messagebox'], - keyID: '1', - counterparty: recipient, - plaintext: Utils.toArray(typeof body === 'string' ? body : JSON.stringify(body), 'utf8') - }, this.originator) + const encryptedMessage = await this.walletClient.encrypt( + { + protocolID: [1, 'messagebox'], + keyID: '1', + counterparty: recipient, + plaintext: Utils.toArray(typeof body === 'string' ? body : JSON.stringify(body), 'utf8') + }, + this.originator + ) outgoingBody = JSON.stringify({ encryptedMessage: Utils.toBase64(encryptedMessage.ciphertext) @@ -825,7 +846,9 @@ export class MessageBoxClient { Logger.log('[MB CLIENT] Received WebSocket acknowledgment:', response) if (response == null || response.status !== 'success') { - Logger.warn('[MB CLIENT] WebSocket message failed or returned unexpected response. Falling back to HTTP.') + Logger.warn( + '[MB CLIENT] WebSocket message failed or returned unexpected response. Falling back to HTTP.' + ) const fallbackMessage: SendMessageParams = { recipient, messageBox, @@ -835,9 +858,7 @@ export class MessageBoxClient { checkPermissions } - this.sendMessage(fallbackMessage, overrideHost) - .then(resolve) - .catch(reject) + this.sendMessage(fallbackMessage, overrideHost).then(resolve).catch(reject) } else { Logger.log('[MB CLIENT] Message sent successfully via WebSocket:', response) resolve(response) @@ -876,9 +897,7 @@ export class MessageBoxClient { checkPermissions } - this.sendMessage(fallbackMessage, overrideHost) - .then(resolve) - .catch(reject) + this.sendMessage(fallbackMessage, overrideHost).then(resolve).catch(reject) } }, 10000) }) @@ -986,30 +1005,38 @@ export class MessageBoxClient { } try { - const finalHost = overrideHost ?? await this.resolveHostForRecipient(message.recipient) + const finalHost = normalizeMessageBoxHost( + overrideHost ?? (await this.resolveHostForRecipient(message.recipient)) + ) - Logger.log('[MB CLIENT] Sending HTTP request to:', `${finalHost}/sendMessage`) + const sendUrl = messageBoxEndpoint(finalHost, '/sendMessage') + Logger.log('[MB CLIENT] Sending HTTP request to:', sendUrl) Logger.log('[MB CLIENT] Request Body:', JSON.stringify(requestBody, null, 2)) await this.ensureIdentityKey() - const response = await this.authFetch.fetch(`${finalHost}/sendMessage`, { + const response = await this.authFetch.fetch(sendUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }) - if (response.bodyUsed) throw new Error('[MB CLIENT ERROR] Response body has already been used!') + if (response.bodyUsed) + throw new Error('[MB CLIENT ERROR] Response body has already been used!') const parsedResponse = await response.json() Logger.log('[MB CLIENT] Raw Response Body:', parsedResponse) if (!response.ok) { - Logger.error(`[MB CLIENT ERROR] Failed to send message. HTTP ${response.status}: ${response.statusText}`) + Logger.error( + `[MB CLIENT ERROR] Failed to send message. HTTP ${response.status}: ${response.statusText}` + ) throw new Error(`Message sending failed: HTTP ${response.status} - ${response.statusText}`) } if (parsedResponse.status !== 'success') { - Logger.error(`[MB CLIENT ERROR] Server returned an error: ${String(parsedResponse.description)}`) + Logger.error( + `[MB CLIENT ERROR] Server returned an error: ${String(parsedResponse.description)}` + ) throw new Error(parsedResponse.description ?? 'Unknown error from server.') } @@ -1023,27 +1050,36 @@ export class MessageBoxClient { } /** Validate required fields on SendMessageParams. */ - private validateSendMessageParams (message: SendMessageParams): void { + private validateSendMessageParams(message: SendMessageParams): void { if (message.recipient == null || message.recipient.trim() === '') { throw new Error('You must provide a message recipient!') } if (message.messageBox == null || message.messageBox.trim() === '') { throw new Error('You must provide a messageBox to send this message into!') } - if (message.body == null || (typeof message.body === 'string' && message.body.trim().length === 0)) { + if ( + message.body == null || + (typeof message.body === 'string' && message.body.trim().length === 0) + ) { throw new Error('Every message must have a body!') } } /** Resolve optional payment data if permission checking is enabled. */ - private async resolveMessagePayment (message: SendMessageParams, overrideHost?: string): Promise { + private async resolveMessagePayment( + message: SendMessageParams, + overrideHost?: string + ): Promise { if (message.checkPermissions !== true) return undefined try { Logger.log('[MB CLIENT] Checking permissions and fees for message...') - const quote = await this.getMessageBoxQuote({ - recipient: message.recipient, - messageBox: message.messageBox - }, overrideHost) as MessageBoxQuote + const quote = (await this.getMessageBoxQuote( + { + recipient: message.recipient, + messageBox: message.messageBox + }, + overrideHost + )) as MessageBoxQuote if (quote.recipientFee === -1) { throw new Error('You have been blocked from sending messages to this recipient.') @@ -1058,20 +1094,30 @@ export class MessageBoxClient { Logger.log('[MB CLIENT] Payment data prepared:', paymentData) return paymentData } catch (error) { - throw new Error(`Permission check failed: ${error instanceof Error ? error.message : 'Unknown error'}`) + throw new Error( + `Permission check failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ) } } /** Generate the HMAC-based message ID. */ - private async generateMessageId (message: SendMessageParams): Promise { + private async generateMessageId(message: SendMessageParams): Promise { try { - const hmac = await this.walletClient.createHmac({ - data: Array.from(new TextEncoder().encode(JSON.stringify(message.body))), - protocolID: [1, 'messagebox'], - keyID: '1', - counterparty: message.recipient - }, this.originator) - return message.messageId ?? Array.from(hmac.hmac).map(b => b.toString(16).padStart(2, '0')).join('') + const hmac = await this.walletClient.createHmac( + { + data: Array.from(new TextEncoder().encode(JSON.stringify(message.body))), + protocolID: [1, 'messagebox'], + keyID: '1', + counterparty: message.recipient + }, + this.originator + ) + return ( + message.messageId ?? + Array.from(hmac.hmac) + .map(b => b.toString(16).padStart(2, '0')) + .join('') + ) } catch (error) { Logger.error('[MB CLIENT ERROR] Failed to generate HMAC:', error) throw new Error('Failed to generate message identifier.') @@ -1079,20 +1125,23 @@ export class MessageBoxClient { } /** Encode the message body (encrypt unless skipEncryption is set). */ - private async encodeMessageBody (message: SendMessageParams): Promise { + private async encodeMessageBody(message: SendMessageParams): Promise { const bodyStr = typeof message.body === 'string' ? message.body : JSON.stringify(message.body) if (message.skipEncryption === true) return bodyStr - const encryptedMessage = await this.walletClient.encrypt({ - protocolID: [1, 'messagebox'], - keyID: '1', - counterparty: message.recipient, - plaintext: Utils.toArray(bodyStr, 'utf8') - }, this.originator) + const encryptedMessage = await this.walletClient.encrypt( + { + protocolID: [1, 'messagebox'], + keyID: '1', + counterparty: message.recipient, + plaintext: Utils.toArray(bodyStr, 'utf8') + }, + this.originator + ) return JSON.stringify({ encryptedMessage: Utils.toBase64(encryptedMessage.ciphertext) }) } /** Ensure myIdentityKey is populated, fetching it if needed. */ - private async ensureIdentityKey (): Promise { + private async ensureIdentityKey(): Promise { if (this.myIdentityKey != null && this.myIdentityKey !== '') return try { const keyResult = await this.walletClient.getPublicKey({ identityKey: true }, this.originator) @@ -1105,34 +1154,53 @@ export class MessageBoxClient { } /** Parse a raw PeerMessage into its envelope components (body, payload, payment). */ - private parseMessageEnvelope (message: PeerMessage): { message: PeerMessage, parsedBody: unknown, messageContent: any, paymentData: Payment | undefined } { - const parsedBody: unknown = typeof message.body === 'string' ? this.tryParse(message.body) : message.body + private parseMessageEnvelope(message: PeerMessage): { + message: PeerMessage + parsedBody: unknown + messageContent: any + paymentData: Payment | undefined + } { + const parsedBody: unknown = + typeof message.body === 'string' ? this.tryParse(message.body) : message.body let messageContent: any = parsedBody let paymentData: Payment | undefined if (parsedBody != null && typeof parsedBody === 'object' && 'message' in parsedBody) { const wrappedMessage = (parsedBody as any).message - messageContent = typeof wrappedMessage === 'string' ? this.tryParse(wrappedMessage) : wrappedMessage + messageContent = + typeof wrappedMessage === 'string' ? this.tryParse(wrappedMessage) : wrappedMessage paymentData = (parsedBody as any).payment } return { message, parsedBody, messageContent, paymentData } } /** Internalize wallet-payment outputs from a payment-carrying message. */ - private async internalizeRecipientPayment (p: { message: PeerMessage, paymentData?: Payment }): Promise { + private async internalizeRecipientPayment(p: { + message: PeerMessage + paymentData?: Payment + }): Promise { try { - Logger.log(`[MB CLIENT] Processing recipient payment in message from ${String(p.message.sender)}…`) - const recipientOutputs = p.paymentData!.outputs.filter(output => output.protocol === 'wallet payment') + Logger.log( + `[MB CLIENT] Processing recipient payment in message from ${String(p.message.sender)}…` + ) + const recipientOutputs = p.paymentData!.outputs.filter( + output => output.protocol === 'wallet payment' + ) if (recipientOutputs.length === 0) { Logger.log('[MB CLIENT] No wallet payment outputs found in payment data') return } - Logger.log(`[MB CLIENT] Internalizing ${recipientOutputs.length} recipient payment output(s)…`) - const result = await this.walletClient.internalizeAction({ - tx: p.paymentData!.tx, - outputs: recipientOutputs, - description: p.paymentData!.description ?? 'MessageBox recipient payment' - }, this.originator) + Logger.log( + `[MB CLIENT] Internalizing ${recipientOutputs.length} recipient payment output(s)…` + ) + const result = await this.walletClient.internalizeAction( + { + tx: p.paymentData!.tx, + outputs: recipientOutputs, + description: p.paymentData!.description ?? 'MessageBox recipient payment' + }, + this.originator + ) if (result.accepted) { Logger.log('[MB CLIENT] Successfully internalized recipient payment') } else { @@ -1144,16 +1212,27 @@ export class MessageBoxClient { } /** Decrypt or unwrap an encrypted message in place. */ - private async decryptMessageBody (p: { message: PeerMessage, parsedBody: unknown, messageContent: any }): Promise { + private async decryptMessageBody(p: { + message: PeerMessage + parsedBody: unknown + messageContent: any + }): Promise { try { - if (p.messageContent != null && typeof p.messageContent === 'object' && typeof (p.messageContent as any).encryptedMessage === 'string') { + if ( + p.messageContent != null && + typeof p.messageContent === 'object' && + typeof (p.messageContent as any).encryptedMessage === 'string' + ) { Logger.log(`[MB CLIENT] Decrypting message from ${String(p.message.sender)}…`) - const decrypted = await this.walletClient.decrypt({ - protocolID: [1, 'messagebox'], - keyID: '1', - counterparty: p.message.sender, - ciphertext: Utils.toArray((p.messageContent as any).encryptedMessage, 'base64') - }, this.originator) + const decrypted = await this.walletClient.decrypt( + { + protocolID: [1, 'messagebox'], + keyID: '1', + counterparty: p.message.sender, + ciphertext: Utils.toArray((p.messageContent as any).encryptedMessage, 'base64') + }, + this.originator + ) p.message.body = this.tryParse(Utils.toUTF8(decrypted.plaintext)) } else { p.message.body = p.messageContent ?? p.parsedBody @@ -1165,14 +1244,25 @@ export class MessageBoxClient { } /** - * Multi-recipient sender. Uses the multi-quote route to: - * - identify blocked recipients - * - compute per-recipient payment - * Then sends to the allowed recipients with payment attached. - */ + * @deprecated Use `sendMessageToRecipients`. This misspelled name remains + * available for source compatibility. + */ async sendMesagetoRecepients( params: SendListParams, overrideHost?: string + ): Promise { + return this.sendMessageToRecipients(params, overrideHost) + } + + /** + * Multi-recipient sender. Uses the multi-quote route to: + * - identify blocked recipients + * - compute per-recipient payment + * Then sends to the allowed recipients with payment attached. + */ + async sendMessageToRecipients( + params: SendListParams, + overrideHost?: string ): Promise { await this.assertInitialized() @@ -1180,24 +1270,36 @@ export class MessageBoxClient { if (!Array.isArray(recipients) || recipients.length === 0) { throw new Error('You must provide at least one recipient!') } + if (recipients.length > 100) { + throw new Error('A batch may include at most 100 recipients.') + } if (!messageBox || messageBox.trim() === '') { throw new Error('You must provide a messageBox to send this message into!') } if (body == null || (typeof body === 'string' && body.trim().length === 0)) { throw new Error('Every message must have a body!') } + if (params.skipEncryption !== true) { + throw new Error( + 'A shared multi-recipient batch cannot be encrypted per recipient. ' + + 'Set skipEncryption: true explicitly or send encrypted messages individually.' + ) + } // 1) Multi-quote for all recipients - const quoteResponse = await this.getMessageBoxQuote({ - recipient: recipients, - messageBox - }, overrideHost) as MessageBoxMultiQuote + const quoteResponse = (await this.getMessageBoxQuote( + { + recipient: recipients, + messageBox + }, + overrideHost + )) as MessageBoxMultiQuote const quotesByRecipient = Array.isArray(quoteResponse?.quotesByRecipient) ? quoteResponse.quotesByRecipient : [] - const blocked = (quoteResponse?.blockedRecipients ?? []) + const blocked = quoteResponse?.blockedRecipients ?? [] const totals = quoteResponse?.totals // 2) Filter allowed recipients @@ -1214,28 +1316,35 @@ export class MessageBoxClient { } // 3) Map recipient -> fees - const perRecipientQuotes = new Map() + const perRecipientQuotes = new Map() for (const q of quotesByRecipient) { - perRecipientQuotes.set(q.recipient, { recipientFee: q.recipientFee, deliveryFee: q.deliveryFee }) + perRecipientQuotes.set(q.recipient, { + recipientFee: q.recipientFee, + deliveryFee: q.deliveryFee + }) } // 4) One delivery agent only (batch goes to one server) const { deliveryAgentIdentityKeyByHost } = quoteResponse - if (!deliveryAgentIdentityKeyByHost || Object.keys(deliveryAgentIdentityKeyByHost).length === 0) { + if ( + !deliveryAgentIdentityKeyByHost || + Object.keys(deliveryAgentIdentityKeyByHost).length === 0 + ) { throw new Error('Missing delivery agent identity keys in quote response.') } if (Object.keys(deliveryAgentIdentityKeyByHost).length > 1 && !overrideHost) { // To keep the single-POST invariant, we require all recipients to share a host - throw new Error('Recipients resolve to multiple hosts. Use overrideHost to force a single server or split by host.') + throw new Error( + 'Recipients resolve to multiple hosts. Use overrideHost to force a single server or split by host.' + ) } // pick the host to POST to - let finalHost = overrideHost ?? await this.resolveHostForRecipient(allowedRecipients[0]) - while (finalHost.endsWith('/')) { - finalHost = finalHost.slice(0, -1) - } - const singleDeliveryKey = deliveryAgentIdentityKeyByHost[finalHost] ?? - Object.values(deliveryAgentIdentityKeyByHost)[0] + const finalHost = normalizeMessageBoxHost( + overrideHost ?? (await this.resolveHostForRecipient(allowedRecipients[0])) + ) + const singleDeliveryKey = + deliveryAgentIdentityKeyByHost[finalHost] ?? Object.values(deliveryAgentIdentityKeyByHost)[0] if (!singleDeliveryKey) { throw new Error('Could not determine server delivery agent identity key.') @@ -1249,14 +1358,19 @@ export class MessageBoxClient { // 6) Build per-recipient messageIds (HMAC), same order as allowedRecipients const bodyBytes = Array.from(new TextEncoder().encode(JSON.stringify(body))) - const messageIds: string[] = await this.mapWithConcurrency(allowedRecipients, 8, async (r) => { - const hmac = await this.walletClient.createHmac({ - data: bodyBytes, - protocolID: [1, 'messagebox'], - keyID: '1', - counterparty: r - }, this.originator) - return Array.from(hmac.hmac).map(b => b.toString(16).padStart(2, '0')).join('') + const messageIds: string[] = await this.mapWithConcurrency(allowedRecipients, 8, async r => { + const hmac = await this.walletClient.createHmac( + { + data: bodyBytes, + protocolID: [1, 'messagebox'], + keyID: '1', + counterparty: r + }, + this.originator + ) + return Array.from(hmac.hmac) + .map(b => b.toString(16).padStart(2, '0')) + .join('') }) // 7) Body: for batch route the server expects a single shared body. @@ -1281,25 +1395,35 @@ export class MessageBoxClient { payment: paymentData } - Logger.log('[MB CLIENT] Sending HTTP request to:', `${finalHost}/sendMessage`) - Logger.log('[MB CLIENT] Request Body (batch):', JSON.stringify({ ...requestBody, payment: { ...paymentData, tx: '' } }, null, 2)) + const sendUrl = messageBoxEndpoint(finalHost, '/sendMessage') + Logger.log('[MB CLIENT] Sending HTTP request to:', sendUrl) + Logger.log( + '[MB CLIENT] Request Body (batch):', + JSON.stringify({ ...requestBody, payment: { ...paymentData, tx: '' } }, null, 2) + ) try { - const response = await this.authFetch.fetch(`${finalHost}/sendMessage`, { + const response = await this.authFetch.fetch(sendUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }) - const parsed = await response.json().catch(() => ({} as any)) + const parsed = await response.json().catch(() => ({}) as any) if (!response.ok || parsed.status !== 'success') { - const msg = response.ok ? (parsed.description ?? 'Unknown server error') : `HTTP ${response.status} - ${response.statusText}` + const msg = response.ok + ? (parsed.description ?? 'Unknown server error') + : `HTTP ${response.status} - ${response.statusText}` throw new Error(msg) } const sent = Array.isArray(parsed.results) ? parsed.results : [] - const failed: Array<{ recipient: string, error: string }> = [] - const { status, description } = buildBatchSendResult(sent.length, allowedRecipients.length, blocked.length) + const failed: Array<{ recipient: string; error: string }> = [] + const { status, description } = buildBatchSendResult( + sent.length, + allowedRecipients.length, + blocked.length + ) return { status, description, sent, blocked, failed, totals } } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error' @@ -1339,7 +1463,7 @@ export class MessageBoxClient { */ async anointHost(host: string): Promise<{ txid: string }> { Logger.log('[MB CLIENT] Starting anointHost...') - if (!host.startsWith('http')) throw new Error('Invalid host URL') + host = normalizeMessageBoxHost(host) const identityKey = await this.getIdentityKey() const overlayTokens = await this.queryAdvertisements(identityKey) @@ -1348,32 +1472,28 @@ export class MessageBoxClient { // Fetch ALL spendable wallet basket outputs and cross-reference with overlay tokens. // Only overlay tokens the wallet considers spendable are safe to spend as inputs. // This prevents stale overlay tokens (spent externally) from breaking the combined tx. - const basketResult = await this.walletClient.listOutputs({ - basket: 'overlay advertisements', - limit: 10000 - }, this.originator) + const basketResult = await this.walletClient.listOutputs( + { + basket: 'overlay advertisements', + limit: 10000 + }, + this.originator + ) const spendableOutpoints = new Set( basketResult.outputs.filter(o => o.spendable).map(o => o.outpoint) ) - const tokensToSpend = overlayTokens.filter(t => spendableOutpoints.has(`${t.txid}.${t.outputIndex}`)) + const tokensToSpend = overlayTokens.filter(t => + spendableOutpoints.has(`${t.txid}.${t.outputIndex}`) + ) const skipped = overlayTokens.length - tokensToSpend.length if (skipped > 0) { Logger.log(`[MB CLIENT] Skipping ${skipped} overlay token(s) not in spendable wallet basket`) } Logger.log(`[MB CLIENT] Revoking ${tokensToSpend.length} spendable token(s) in combined tx`) - const fields: number[][] = [ - Utils.toArray(identityKey, 'hex'), - Utils.toArray(host, 'utf8') - ] + const fields: number[][] = [Utils.toArray(identityKey, 'hex'), Utils.toArray(host, 'utf8')] const pushdrop = new PushDrop(this.walletClient, this.originator) - const script = await pushdrop.lock( - fields, - [1, 'messagebox advertisement'], - '1', - 'anyone', - true - ) + const script = await pushdrop.lock(fields, [1, 'messagebox advertisement'], '1', 'anyone', true) Logger.log('[MB CLIENT] PushDrop script:', script.toASM()) try { @@ -1386,32 +1506,44 @@ export class MessageBoxClient { inputBEEF = mergedBeef.toBinary() } - const { signableTransaction, tx: directTx, txid: directTxid } = await this.walletClient.createAction({ - description: 'Anoint host for overlay routing', - ...(inputBEEF !== undefined && { - inputBEEF, - inputs: tokensToSpend.map(token => ({ - outpoint: `${token.txid}.${token.outputIndex}`, - unlockingScriptLength: 73, - inputDescription: `Revoking advertisement for ${token.host}` - })) - }), - outputs: [{ - basket: 'overlay advertisements', - lockingScript: script.toHex(), - satoshis: 1, - outputDescription: 'Overlay advertisement output' - }], - options: { randomizeOutputs: false, acceptDelayedBroadcast: false } - }, this.originator) + const { + signableTransaction, + tx: directTx, + txid: directTxid + } = await this.walletClient.createAction( + { + description: 'Anoint host for overlay routing', + ...(inputBEEF !== undefined && { + inputBEEF, + inputs: tokensToSpend.map(token => ({ + outpoint: `${token.txid}.${token.outputIndex}`, + unlockingScriptLength: 73, + inputDescription: `Revoking advertisement for ${token.host}` + })) + }), + outputs: [ + { + basket: 'overlay advertisements', + lockingScript: script.toHex(), + satoshis: 1, + outputDescription: 'Overlay advertisement output' + } + ], + options: { randomizeOutputs: false, acceptDelayedBroadcast: false } + }, + this.originator + ) if (signableTransaction === undefined) { if (directTx === undefined) throw new Error('Anoint failed: no transaction returned') Logger.log('[MB CLIENT] Transaction created (no inputs to sign):', directTxid) - const broadcaster = new TopicBroadcaster(['tm_messagebox'], { networkPreset: this.networkPreset }) + const broadcaster = new TopicBroadcaster(['tm_messagebox'], { + networkPreset: this.networkPreset + }) const result = await broadcaster.broadcast(Transaction.fromAtomicBEEF(directTx)) Logger.log('[MB CLIENT] Advertisement broadcast succeeded. TXID:', result.txid) - if (typeof result.txid !== 'string') throw new Error('Anoint failed: broadcast did not return a txid') + if (typeof result.txid !== 'string') + throw new Error('Anoint failed: broadcast did not return a txid') return { txid: result.txid } } @@ -1435,20 +1567,27 @@ export class MessageBoxClient { spends[i] = { unlockingScript: finalUnlockScript.toHex() } } - const { tx: signedTx, txid: signedTxid } = await this.walletClient.signAction({ - reference: signableTransaction.reference, - spends, - options: { acceptDelayedBroadcast: false } - }, this.originator) + const { tx: signedTx, txid: signedTxid } = await this.walletClient.signAction( + { + reference: signableTransaction.reference, + spends, + options: { acceptDelayedBroadcast: false } + }, + this.originator + ) - if (signedTx === undefined) throw new Error('Anoint failed: signing did not return a transaction') + if (signedTx === undefined) + throw new Error('Anoint failed: signing did not return a transaction') Logger.log('[MB CLIENT] Transaction created:', signedTxid) - const broadcaster = new TopicBroadcaster(['tm_messagebox'], { networkPreset: this.networkPreset }) + const broadcaster = new TopicBroadcaster(['tm_messagebox'], { + networkPreset: this.networkPreset + }) const result = await broadcaster.broadcast(Transaction.fromAtomicBEEF(signedTx)) Logger.log('[MB CLIENT] Advertisement broadcast succeeded. TXID:', result.txid) - if (typeof result.txid !== 'string') throw new Error('Anoint failed: broadcast did not return a txid') + if (typeof result.txid !== 'string') + throw new Error('Anoint failed: broadcast did not return a txid') return { txid: result.txid } } catch (err) { Logger.error('[MB CLIENT ERROR] anointHost threw:', err) @@ -1474,17 +1613,20 @@ export class MessageBoxClient { Logger.log('[MB CLIENT] Starting revokeHost...') const outpoint = `${advertisementToken.txid}.${advertisementToken.outputIndex}` try { - const { signableTransaction } = await this.walletClient.createAction({ - description: 'Revoke MessageBox host advertisement', - inputBEEF: advertisementToken.beef, - inputs: [ - { - outpoint, - unlockingScriptLength: 73, - inputDescription: 'Revoking host advertisement token' - } - ] - }, this.originator) + const { signableTransaction } = await this.walletClient.createAction( + { + description: 'Revoke MessageBox host advertisement', + inputBEEF: advertisementToken.beef, + inputs: [ + { + outpoint, + unlockingScriptLength: 73, + inputDescription: 'Revoking host advertisement token' + } + ] + }, + this.originator + ) if (signableTransaction === undefined) { throw new Error('Failed to create signable transaction.') @@ -1512,17 +1654,20 @@ export class MessageBoxClient { const finalUnlockScript = await unlocker.sign(partialTx, 0) // Complete signing with the final unlock script - const { tx: signedTx } = await this.walletClient.signAction({ - reference: signableTransaction.reference, - spends: { - 0: { - unlockingScript: finalUnlockScript.toHex() + const { tx: signedTx } = await this.walletClient.signAction( + { + reference: signableTransaction.reference, + spends: { + 0: { + unlockingScript: finalUnlockScript.toHex() + } + }, + options: { + acceptDelayedBroadcast: false } }, - options: { - acceptDelayedBroadcast: false - } - }, this.originator) + this.originator + ) if (signedTx === undefined) { throw new Error('Failed to finalize the transaction signature.') @@ -1577,19 +1722,21 @@ export class MessageBoxClient { * messages.forEach(msg => console.log(msg.sender, msg.body)) * // Payments included with messages are automatically received */ - async listMessages({ messageBox, host, acceptPayments }: ListMessagesParams): Promise { + async listMessages({ + messageBox, + host, + acceptPayments + }: ListMessagesParams): Promise { if (typeof acceptPayments !== 'boolean') { acceptPayments = true } - if (messageBox.trim() === '') { + if (typeof messageBox !== 'string' || messageBox.trim() === '') { throw new Error('MessageBox cannot be empty') } - let hosts: string[] = host != null ? [host] : [] + let hosts: string[] = host != null ? [normalizeMessageBoxHost(host)] : [] if (hosts.length === 0) { - const advertisedHosts = await this.queryAdvertisements( - await this.getIdentityKey() - ) + const advertisedHosts = await this.queryAdvertisements(await this.getIdentityKey()) hosts = Array.from(new Set([this.host, ...advertisedHosts.map(h => h.host)])) } @@ -1597,15 +1744,7 @@ export class MessageBoxClient { const fetchFromHost = async (host: string): Promise => { try { Logger.log(`[MB CLIENT] Listing messages from ${host}…`) - const res = await this.authFetch.fetch(`${host}/listMessages`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ messageBox }) - }) - if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`) - const data = await res.json() - if (data.status === 'error') throw new Error(data.description ?? 'Unknown server error') - return data.messages as PeerMessage[] + return await this.fetchMessagePages(host, messageBox) } catch (err) { Logger.log(`[MB CLIENT DEBUG] listMessages failed for ${host}:`, err) throw err // re-throw to be caught in the settled promise @@ -1644,23 +1783,22 @@ export class MessageBoxClient { const parsed = messages.map(message => this.parseMessageEnvelope(message)) if (acceptPayments) { - const paymentJobs = parsed.filter(p => p.paymentData?.tx != null && p.paymentData.outputs != null) - await this.mapWithConcurrency(paymentJobs, 2, async (p) => { + const paymentJobs = parsed.filter( + p => p.paymentData?.tx != null && p.paymentData.outputs != null + ) + await this.mapWithConcurrency(paymentJobs, 2, async p => { await this.internalizeRecipientPayment(p) return null }) } - await this.mapWithConcurrency(parsed, 4, async (p) => { + await this.mapWithConcurrency(parsed, 4, async p => { await this.decryptMessageBody(p) return null }) // Sort newest‑first for a deterministic order - messages.sort( - (a, b) => - Number((b as any).timestamp ?? 0) - Number((a as any).timestamp ?? 0) - ) + messages.sort((a, b) => Number((b as any).timestamp ?? 0) - Number((a as any).timestamp ?? 0)) return messages } @@ -1697,29 +1835,21 @@ export class MessageBoxClient { * console.log(messages) */ async listMessagesLite({ messageBox, host }: ListMessagesParams): Promise { - const res = await this.authFetch.fetch(`${host!}/listMessages`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ messageBox }) - }) - const data = await res.json() - if (data.status === 'error') throw new Error(data.description ?? 'Unknown server error') - const messages = data.messages as PeerMessage[] + if (typeof messageBox !== 'string' || messageBox.trim() === '') { + throw new Error('MessageBox cannot be empty') + } + const finalHost = normalizeMessageBoxHost(host ?? this.host) + const messages = await this.fetchMessagePages(finalHost, messageBox) - await this.mapWithConcurrency(messages, 4, async (message) => { + await this.mapWithConcurrency(messages, 4, async message => { try { const parsedBody: unknown = typeof message.body === 'string' ? this.tryParse(message.body) : message.body let messageContent: any = parsedBody - if ( - parsedBody != null && - typeof parsedBody === 'object' && - 'message' in parsedBody - ) { + if (parsedBody != null && typeof parsedBody === 'object' && 'message' in parsedBody) { const wrappedMessage = (parsedBody as any).message - messageContent = typeof wrappedMessage === 'string' - ? this.tryParse(wrappedMessage) - : wrappedMessage + messageContent = + typeof wrappedMessage === 'string' ? this.tryParse(wrappedMessage) : wrappedMessage } if ( messageContent != null && @@ -1730,10 +1860,7 @@ export class MessageBoxClient { protocolID: [1, 'messagebox'], keyID: '1', counterparty: message.sender, - ciphertext: Utils.toArray( - messageContent.encryptedMessage, - 'base64' - ) + ciphertext: Utils.toArray(messageContent.encryptedMessage, 'base64') }) const decryptedText = Utils.toUTF8(decrypted.plaintext) message.body = this.tryParse(decryptedText) @@ -1741,10 +1868,7 @@ export class MessageBoxClient { message.body = messageContent ?? parsedBody } } catch (err) { - Logger.error( - '[MB CLIENT ERROR] Failed to parse or decrypt message in list:', - err - ) + Logger.error('[MB CLIENT ERROR] Failed to parse or decrypt message in list:', err) message.body = '[Error: Failed to decrypt or parse message]' } return null @@ -1752,6 +1876,41 @@ export class MessageBoxClient { return messages } + private async fetchMessagePages(host: string, messageBox: string): Promise { + const pageSize = 1_000 + const maximumPages = 100 + const messages: PeerMessage[] = [] + + for (let page = 0; page < maximumPages; page++) { + const offset = page * pageSize + const res = await this.authFetch.fetch(messageBoxEndpoint(host, '/listMessages'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messageBox, limit: pageSize, offset }) + }) + if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`) + + const data = await res.json() + if (data.status === 'error') { + throw new Error(data.description ?? 'Unknown server error') + } + if (!Array.isArray(data.messages)) { + throw new Error('Message Box server returned an invalid messages payload') + } + messages.push(...(data.messages as PeerMessage[])) + + // Legacy Message Box servers returned the complete collection without + // pagination metadata and may ignore limit/offset. Only continue when a + // pagination-aware server explicitly advertises another page. + if (data.hasMore !== true) return messages + } + + throw new Error( + `Message Box pagination exceeded ${maximumPages * pageSize} messages; ` + + 'acknowledge messages or request smaller application-level batches.' + ) + } + /** * @method tryParse * @private @@ -1788,7 +1947,7 @@ export class MessageBoxClient { } const workerCount = Math.max(1, Math.min(limit, items.length)) - const results: R[] = new Array(items.length) + const results: R[] = [] let nextIndex = 0 const workers = Array.from({ length: workerCount }, async () => { @@ -1834,11 +1993,7 @@ export class MessageBoxClient { let paymentData: Payment | undefined - if ( - parsedBody != null && - typeof parsedBody === 'object' && - 'message' in parsedBody - ) { + if (parsedBody != null && typeof parsedBody === 'object' && 'message' in parsedBody) { paymentData = (parsedBody as any).payment } @@ -1856,9 +2011,7 @@ export class MessageBoxClient { ) if (recipientOutputs.length < 1) { - Logger.log( - '[MB CLIENT] No wallet payment outputs found in payment data' - ) + Logger.log('[MB CLIENT] No wallet payment outputs found in payment data') return false } @@ -1873,21 +2026,14 @@ export class MessageBoxClient { }) if (internalizeResult.accepted) { - Logger.log( - '[MB CLIENT] Successfully internalized recipient payment' - ) + Logger.log('[MB CLIENT] Successfully internalized recipient payment') return true } else { - Logger.warn( - '[MB CLIENT] Recipient payment internalization was not accepted' - ) + Logger.warn('[MB CLIENT] Recipient payment internalization was not accepted') return false } } catch (paymentError) { - Logger.error( - '[MB CLIENT ERROR] Failed to internalize recipient payment:', - paymentError - ) + Logger.error('[MB CLIENT ERROR] Failed to internalize recipient payment:', paymentError) return false } } @@ -1922,7 +2068,7 @@ export class MessageBoxClient { Logger.log(`[MB CLIENT] Acknowledging messages ${JSON.stringify(messageIds)}…`) - let hosts: string[] = host != null ? [host] : [] + let hosts: string[] = host != null ? [normalizeMessageBoxHost(host)] : [] if (hosts.length === 0) { // 1. Determine all hosts (advertised + default) const identityKey = await this.getIdentityKey() @@ -1933,7 +2079,7 @@ export class MessageBoxClient { // 2. Dispatch parallel acknowledge requests const ackFromHost = async (host: string): Promise => { try { - const res = await this.authFetch.fetch(`${host}/acknowledgeMessage`, { + const res = await this.authFetch.fetch(messageBoxEndpoint(host, '/acknowledgeMessage'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageIds }) @@ -1966,9 +2112,7 @@ export class MessageBoxClient { for (const r of settled) { if (r.status === 'rejected') errs.push(r.reason) } - throw new Error( - `Failed to acknowledge messages on all hosts: ${errs.map(String).join('; ')}` - ) + throw new Error(`Failed to acknowledge messages on all hosts: ${errs.map(String).join('; ')}`) } // =========================== @@ -2001,11 +2145,11 @@ export class MessageBoxClient { params: SetMessageBoxPermissionParams, overrideHost?: string ): Promise { - const finalHost = overrideHost ?? this.host + const finalHost = normalizeMessageBoxHost(overrideHost ?? this.host) Logger.log('[MB CLIENT] Setting messageBox permission...') - const response = await this.authFetch.fetch(`${finalHost}/permissions/set`, { + const response = await this.authFetch.fetch(messageBoxEndpoint(finalHost, '/permissions/set'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -2017,7 +2161,9 @@ export class MessageBoxClient { if (!response.ok) { const errorData = await response.json().catch(() => ({})) - throw new Error(`Failed to set permission: HTTP ${response.status} - ${String(errorData.description) !== '' ? String(errorData.description) : response.statusText}`) + throw new Error( + `Failed to set permission: HTTP ${response.status} - ${String(errorData.description) !== '' ? String(errorData.description) : response.statusText}` + ) } const { status, description } = await response.json() @@ -2048,22 +2194,28 @@ export class MessageBoxClient { params: GetMessageBoxPermissionParams, overrideHost?: string ): Promise { - const finalHost = overrideHost ?? await this.resolveHostForRecipient(params.recipient) + const finalHost = normalizeMessageBoxHost( + overrideHost ?? (await this.resolveHostForRecipient(params.recipient)) + ) const queryParams = new URLSearchParams({ - recipient: params.recipient, messageBox: params.messageBox, ...(params.sender != null && { sender: params.sender }) }) Logger.log('[MB CLIENT] Getting messageBox permission...') - const response = await this.authFetch.fetch(`${finalHost}/permissions/get?${queryParams.toString()}`, { - method: 'GET' - }) + const response = await this.authFetch.fetch( + `${messageBoxEndpoint(finalHost, '/permissions/get')}?${queryParams.toString()}`, + { + method: 'GET' + } + ) if (!response.ok) { const errorData = await response.json().catch(() => ({})) - throw new Error(`Failed to get permission: HTTP ${response.status} - ${String(errorData.description) !== '' ? String(errorData.description) : response.statusText}`) + throw new Error( + `Failed to get permission: HTTP ${response.status} - ${String(errorData.description) !== '' ? String(errorData.description) : response.statusText}` + ) } const data = await response.json() @@ -2071,7 +2223,7 @@ export class MessageBoxClient { throw new Error(data.description ?? 'Failed to get permission') } - return data.permission + return data.permission ?? null } /** @@ -2105,19 +2257,18 @@ export class MessageBoxClient { messageBox: string, overrideHost?: string ): Promise { - const finalHost = overrideHost ?? await this.resolveHostForRecipient(recipient) + const finalHost = normalizeMessageBoxHost( + overrideHost ?? (await this.resolveHostForRecipient(recipient)) + ) const queryParams = new URLSearchParams({ recipient, messageBox }) Logger.log('[MB CLIENT] Getting messageBox quote (single)...') - console.log('HELP IM QUOTING', `${finalHost}/permissions/quote?${queryParams.toString()}`) - const response = await this.authFetch.fetch( - `${finalHost}/permissions/quote?${queryParams.toString()}`, - { method: 'GET' } - ) - console.log('server response from getquote]', response) + const quoteUrl = `${messageBoxEndpoint(finalHost, '/permissions/quote')}?${queryParams.toString()}` + Logger.log('[MB CLIENT] Quote request:', quoteUrl) + const response = await this.authFetch.fetch(quoteUrl, { method: 'GET' }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) throw new Error( @@ -2131,7 +2282,6 @@ export class MessageBoxClient { } const deliveryAgentIdentityKey = response.headers.get('x-bsv-auth-identity-key') - console.log('deliveryAgentIdentityKey', deliveryAgentIdentityKey) if (deliveryAgentIdentityKey == null) { throw new Error('Failed to get quote: Delivery agent did not provide their identity key') } @@ -2153,14 +2303,15 @@ export class MessageBoxClient { } Logger.log('[MB CLIENT] Getting messageBox quotes (multi)...') - console.log('[MB CLIENT] Getting messageBox quotes (multi)...') const hostGroups = await this.groupQuoteRecipientsByHost(recipients, overrideHost) const accumulator = this.createMultiQuoteAccumulator() - await Promise.all(Array.from(hostGroups.entries()).map(async ([host, group]) => { - const payload = await this.fetchQuotePayloadForHost(host, group, messageBox, accumulator) - this.mergeQuotePayload(payload, host, group, messageBox, accumulator) - })) + await Promise.all( + Array.from(hostGroups.entries()).map(async ([host, group]) => { + const payload = await this.fetchQuotePayloadForHost(host, group, messageBox, accumulator) + this.mergeQuotePayload(payload, host, group, messageBox, accumulator) + }) + ) const { deliveryFees, recipientFees } = accumulator @@ -2190,9 +2341,12 @@ export class MessageBoxClient { recipients: PubKeyHex[], overrideHost?: string ): Promise> { - const resolvedHosts = overrideHost != null - ? recipients.map(() => overrideHost) - : await this.mapWithConcurrency(recipients, 8, (recipient) => this.resolveHostForRecipient(recipient)) + const resolvedHosts = + overrideHost != null + ? recipients.map(() => normalizeMessageBoxHost(overrideHost)) + : await this.mapWithConcurrency(recipients, 8, recipient => + this.resolveHostForRecipient(recipient) + ) const hostGroups = new Map() for (let i = 0; i < recipients.length; i++) { @@ -2215,7 +2369,7 @@ export class MessageBoxClient { for (const recipient of groupRecipients) qp.append('recipient', recipient) qp.set('messageBox', messageBox) - const url = `${host}/permissions/quote?${qp.toString()}` + const url = `${messageBoxEndpoint(host, '/permissions/quote')}?${qp.toString()}` Logger.log('[MB CLIENT] Multi-quote GET:', url) const response = await this.authFetch.fetch(url, { method: 'GET' }) @@ -2259,21 +2413,25 @@ export class MessageBoxClient { quotesByRecipient: MessageBoxRecipientQuote[] blockedRecipients?: PubKeyHex[] } { - return typeof payload === 'object' && + return ( + typeof payload === 'object' && payload != null && Array.isArray((payload as { quotesByRecipient?: unknown }).quotesByRecipient) + ) } private isSingleQuotePayload(payload: unknown): payload is { quote: Pick } { - return typeof payload === 'object' && + return ( + typeof payload === 'object' && payload != null && (payload as { quote?: unknown }).quote != null + ) } private mergeRecipientQuotes( - payload: { quotesByRecipient: MessageBoxRecipientQuote[], blockedRecipients?: PubKeyHex[] }, + payload: { quotesByRecipient: MessageBoxRecipientQuote[]; blockedRecipients?: PubKeyHex[] }, accumulator: MessageBoxMultiQuoteAccumulator ): void { for (const quote of payload.quotesByRecipient) { @@ -2353,12 +2511,15 @@ export class MessageBoxClient { * offset: 0 * }) */ - async listMessageBoxPermissions(params?: ListPermissionsParams, overrideHost?: string): Promise { - const finalHost = overrideHost ?? this.host + async listMessageBoxPermissions( + params?: ListPermissionsParams, + overrideHost?: string + ): Promise { + const finalHost = normalizeMessageBoxHost(overrideHost ?? this.host) const queryParams = new URLSearchParams() if (params?.messageBox != null) { - queryParams.set('message_box', params.messageBox) + queryParams.set('messageBox', params.messageBox) } if (params?.limit !== undefined) { queryParams.set('limit', params.limit.toString()) @@ -2369,13 +2530,18 @@ export class MessageBoxClient { Logger.log('[MB CLIENT] Listing messageBox permissions with params:', queryParams.toString()) - const response = await this.authFetch.fetch(`${finalHost}/permissions/list?${queryParams.toString()}`, { - method: 'GET' - }) + const response = await this.authFetch.fetch( + `${messageBoxEndpoint(finalHost, '/permissions/list')}?${queryParams.toString()}`, + { + method: 'GET' + } + ) if (!response.ok) { const errorData = await response.json().catch(() => ({})) - throw new Error(`Failed to list permissions: HTTP ${response.status} - ${String(errorData.description) !== '' ? String(errorData.description) : response.statusText}`) + throw new Error( + `Failed to list permissions: HTTP ${response.status} - ${String(errorData.description) !== '' ? String(errorData.description) : response.statusText}` + ) } const data = await response.json() @@ -2383,14 +2549,41 @@ export class MessageBoxClient { throw new Error(data.description ?? 'Failed to list permissions') } - return data.permissions.map((p: any) => ({ - sender: p.sender, - messageBox: p.message_box, - recipientFee: p.recipient_fee, - status: MessageBoxClient.getStatusFromFee(p.recipient_fee), - createdAt: p.created_at, - updatedAt: p.updated_at - })) + if (!Array.isArray(data.permissions)) { + throw new Error('Failed to list permissions: server returned an invalid permissions payload') + } + + return data.permissions.map((permission: unknown) => { + if (typeof permission !== 'object' || permission == null) { + throw new Error('Failed to list permissions: server returned an invalid permission record') + } + + const record = permission as Record + const sender = record.sender + const messageBox = record.messageBox ?? record.message_box + const recipientFee = record.recipientFee ?? record.recipient_fee + const createdAt = record.createdAt ?? record.created_at + const updatedAt = record.updatedAt ?? record.updated_at + + if ( + (sender !== null && typeof sender !== 'string') || + typeof messageBox !== 'string' || + !Number.isSafeInteger(recipientFee) || + typeof createdAt !== 'string' || + typeof updatedAt !== 'string' + ) { + throw new Error('Failed to list permissions: server returned an invalid permission record') + } + + return { + sender, + messageBox, + recipientFee: recipientFee as number, + status: MessageBoxClient.getStatusFromFee(recipientFee as number), + createdAt, + updatedAt + } + }) } // =========================== @@ -2412,12 +2605,19 @@ export class MessageBoxClient { * await client.allowNotificationsFromPeer('03abc123...') // Always allow * await client.allowNotificationsFromPeer('03def456...', 5) // Allow for 5 sats */ - async allowNotificationsFromPeer(identityKey: PubKeyHex, recipientFee: number = 0, overrideHost?: string): Promise { - await this.setMessageBoxPermission({ - messageBox: 'notifications', - sender: identityKey, - recipientFee - }, overrideHost) + async allowNotificationsFromPeer( + identityKey: PubKeyHex, + recipientFee: number = 0, + overrideHost?: string + ): Promise { + await this.setMessageBoxPermission( + { + messageBox: 'notifications', + sender: identityKey, + recipientFee + }, + overrideHost + ) } /** @@ -2433,11 +2633,14 @@ export class MessageBoxClient { * await client.denyNotificationsFromPeer('03spam123...') */ async denyNotificationsFromPeer(identityKey: PubKeyHex, overrideHost?: string): Promise { - await this.setMessageBoxPermission({ - messageBox: 'notifications', - sender: identityKey, - recipientFee: -1 - }, overrideHost) + await this.setMessageBoxPermission( + { + messageBox: 'notifications', + sender: identityKey, + recipientFee: -1 + }, + overrideHost + ) } /** @@ -2453,13 +2656,19 @@ export class MessageBoxClient { * const status = await client.checkPeerNotificationStatus('03abc123...') * console.log(status.allowed) // true/false */ - async checkPeerNotificationStatus(identityKey: PubKeyHex, overrideHost?: string): Promise { + async checkPeerNotificationStatus( + identityKey: PubKeyHex, + overrideHost?: string + ): Promise { const myIdentityKey = await this.getIdentityKey() - return await this.getMessageBoxPermission({ - recipient: myIdentityKey, - messageBox: 'notifications', - sender: identityKey - }, overrideHost) + return await this.getMessageBoxPermission( + { + recipient: myIdentityKey, + messageBox: 'notifications', + sender: identityKey + }, + overrideHost + ) } /** @@ -2505,20 +2714,58 @@ export class MessageBoxClient { // Single recipient → keep original flow if (!Array.isArray(recipient)) { - return await this.sendMessage({ - recipient, - messageBox: 'notifications', - body, - checkPermissions: true - }, overrideHost) + return await this.sendMessage( + { + recipient, + messageBox: 'notifications', + body, + checkPermissions: true + }, + overrideHost + ) + } + + // Shared batch payloads cannot be encrypted to multiple counterparties. + // Preserve encryption-by-default by sending bounded individual requests. + const outcomes = await this.mapWithConcurrency(recipient, 8, async target => { + try { + const response = await this.sendMessage( + { + recipient: target, + messageBox: 'notifications', + body, + checkPermissions: true + }, + overrideHost + ) + return { recipient: target, messageId: response.messageId } + } catch (error) { + return { + recipient: target, + error: error instanceof Error ? error.message : 'Unknown error' + } + } + }) + const sent = outcomes.filter( + (outcome): outcome is { recipient: PubKeyHex; messageId: string } => 'messageId' in outcome + ) + const failed = outcomes.filter( + (outcome): outcome is { recipient: PubKeyHex; error: string } => 'error' in outcome + ) + let status: SendListResult['status'] = 'error' + if (sent.length === recipient.length) { + status = 'success' + } else if (sent.length > 0) { + status = 'partial' } - // Multiple recipients → new flow - return await this.sendMesagetoRecepients({ - recipients: recipient, - messageBox: 'notifications', - body - }, overrideHost) + return { + status, + description: `Sent ${sent.length} of ${recipient.length} encrypted notifications.`, + sent, + blocked: [], + failed + } } /** @@ -2547,6 +2794,12 @@ export class MessageBoxClient { if (params.fcmToken == null || params.fcmToken.trim() === '') { throw new Error('fcmToken is required and must be a non-empty string') } + if (params.fcmToken.trim().length > 500) { + throw new Error('fcmToken must not exceed 500 characters') + } + if (params.deviceId != null && params.deviceId.trim().length > 255) { + throw new Error('deviceId must not exceed 255 characters') + } // Validate platform if provided const validPlatforms = ['ios', 'android', 'web'] @@ -2554,11 +2807,11 @@ export class MessageBoxClient { throw new Error('platform must be one of: ios, android, web') } - const finalHost = overrideHost ?? this.host + const finalHost = normalizeMessageBoxHost(overrideHost ?? this.host) Logger.log('[MB CLIENT] Registering device for FCM notifications...') - const response = await this.authFetch.fetch(`${finalHost}/registerDevice`, { + const response = await this.authFetch.fetch(messageBoxEndpoint(finalHost, '/registerDevice'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -2570,7 +2823,8 @@ export class MessageBoxClient { if (!response.ok) { const errorData = await response.json().catch(() => ({})) - const description = typeof errorData.description === 'string' ? errorData.description : response.statusText + const description = + typeof errorData.description === 'string' ? errorData.description : response.statusText throw new Error(`Failed to register device: HTTP ${response.status} - ${description}`) } @@ -2588,14 +2842,15 @@ export class MessageBoxClient { } /** - * List all registered devices for push notifications. + * List one bounded page of registered devices for push notifications. * * @async * @param {string} [overrideHost] - Optional host override * @returns {Promise} Array of registered devices * * @description - * Retrieves all devices registered by the authenticated user for FCM push notifications. + * Retrieves a bounded page of devices registered by the authenticated user for FCM push + * notifications. * Only shows devices belonging to the current user (authenticated via AuthFetch). * * @example @@ -2606,19 +2861,26 @@ export class MessageBoxClient { * }) */ async listRegisteredDevices( - overrideHost?: string + overrideHost?: string, + pagination: { limit?: number; offset?: number } = {} ): Promise { - const finalHost = overrideHost ?? this.host + const finalHost = normalizeMessageBoxHost(overrideHost ?? this.host) + const query = new URLSearchParams() + if (pagination.limit != null) query.set('limit', String(pagination.limit)) + if (pagination.offset != null) query.set('offset', String(pagination.offset)) + const suffix = query.size > 0 ? `?${query.toString()}` : '' Logger.log('[MB CLIENT] Listing registered devices...') - const response = await this.authFetch.fetch(`${finalHost}/devices`, { - method: 'GET' - }) + const response = await this.authFetch.fetch( + `${messageBoxEndpoint(finalHost, '/devices')}${suffix}`, + { method: 'GET' } + ) if (!response.ok) { const errorData = await response.json().catch(() => ({})) - const description = typeof errorData.description === 'string' ? errorData.description : response.statusText + const description = + typeof errorData.description === 'string' ? errorData.description : response.statusText throw new Error(`Failed to list devices: HTTP ${response.status} - ${description}`) } @@ -2642,31 +2904,31 @@ export class MessageBoxClient { } /** - * @method createMessagePayment - * @private - * @param {string} recipient - Recipient's identity key. - * @param {MessageBoxQuote} quote - Quote object containing recipient and delivery fees. - * @param {string} [description='MessageBox delivery payment'] - Description for the payment action. - * @param {string} [originator] - Optional originator to use for wallet operations. - * @returns {Promise} - Payment data including the transaction and remittance outputs. - * - * @description - * Constructs and signs a payment transaction covering both delivery and recipient fees for - * message delivery, based on a previously obtained quote. - * - * The transaction includes: - * - An optional delivery fee output for the MessageBox server. - * - An optional recipient fee output for the message recipient. - * - * Payment remittance metadata (derivation prefix/suffix, sender identity) is embedded to allow - * the payee to derive their private key and spend the output. - * - * @throws {Error} If no payment is required, key derivation fails, or the action creation fails. - * - * @example - * const payment = await client.createMessagePayment(recipientKey, quote) - * await client.sendMessage({ recipient, messageBox, body, payment }) - */ + * @method createMessagePayment + * @private + * @param {string} recipient - Recipient's identity key. + * @param {MessageBoxQuote} quote - Quote object containing recipient and delivery fees. + * @param {string} [description='MessageBox delivery payment'] - Description for the payment action. + * @param {string} [originator] - Optional originator to use for wallet operations. + * @returns {Promise} - Payment data including the transaction and remittance outputs. + * + * @description + * Constructs and signs a payment transaction covering both delivery and recipient fees for + * message delivery, based on a previously obtained quote. + * + * The transaction includes: + * - An optional delivery fee output for the MessageBox server. + * - An optional recipient fee output for the message recipient. + * + * Payment remittance metadata (derivation prefix/suffix, sender identity) is embedded to allow + * the payee to derive their private key and spend the output. + * + * @throws {Error} If no payment is required, key derivation fails, or the action creation fails. + * + * @example + * const payment = await client.createMessagePayment(recipientKey, quote) + * await client.sendMessage({ recipient, messageBox, body, payment }) + */ private async createMessagePayment( recipient: string, quote: MessageBoxQuote, @@ -2676,7 +2938,9 @@ export class MessageBoxClient { throw new Error('No payment required') } - Logger.log(`[MB CLIENT] Creating payment transaction for ${quote.recipientFee} sats (delivery: ${quote.deliveryFee}, recipient: ${quote.recipientFee})`) + Logger.log( + `[MB CLIENT] Creating payment transaction for ${quote.recipientFee} sats (delivery: ${quote.deliveryFee}, recipient: ${quote.recipientFee})` + ) const outputs: InternalizeOutput[] = [] const createActionOutputs: CreateActionOutput[] = [] @@ -2691,15 +2955,20 @@ export class MessageBoxClient { const derivationSuffix = Utils.toBase64(Random(32)) // Get host's derived public key - console.log('delivery agent:', quote.deliveryAgentIdentityKey) - const { publicKey: derivedKeyResult } = await this.walletClient.getPublicKey({ - protocolID: [2, '3241645161d8'], - keyID: `${derivationPrefix} ${derivationSuffix}`, - counterparty: quote.deliveryAgentIdentityKey - }, this.originator) + Logger.log('[MB CLIENT] Delivery agent:', quote.deliveryAgentIdentityKey) + const { publicKey: derivedKeyResult } = await this.walletClient.getPublicKey( + { + protocolID: [2, '3241645161d8'], + keyID: `${derivationPrefix} ${derivationSuffix}`, + counterparty: quote.deliveryAgentIdentityKey + }, + this.originator + ) // Create locking script using host's public key - const lockingScript = new P2PKH().lock(PublicKey.fromString(derivedKeyResult).toAddress()).toHex() + const lockingScript = new P2PKH() + .lock(PublicKey.fromString(derivedKeyResult).toAddress()) + .toHex() // Add to createAction outputs createActionOutputs.push({ @@ -2737,11 +3006,13 @@ export class MessageBoxClient { }) if (derivedKeyResult == null || derivedKeyResult.trim() === '') { - throw new Error('Failed to derive recipient\'s public key') + throw new Error("Failed to derive recipient's public key") } // Create locking script using recipient's public key - const lockingScript = new P2PKH().lock(PublicKey.fromString(derivedKeyResult).toAddress()).toHex() + const lockingScript = new P2PKH() + .lock(PublicKey.fromString(derivedKeyResult).toAddress()) + .toHex() // Add to createAction outputs createActionOutputs.push({ @@ -2766,11 +3037,14 @@ export class MessageBoxClient { }) } - const { tx } = await this.walletClient.createAction({ - description, - outputs: createActionOutputs, - options: { randomizeOutputs: false, acceptDelayedBroadcast: false } - }, this.originator) + const { tx } = await this.walletClient.createAction( + { + description, + outputs: createActionOutputs, + options: { randomizeOutputs: false, acceptDelayedBroadcast: false } + }, + this.originator + ) if (tx == null) { throw new Error('Failed to create payment transaction') @@ -2786,7 +3060,7 @@ export class MessageBoxClient { private async createMessagePaymentBatch( recipients: string[], - perRecipientQuotes: Map, + perRecipientQuotes: Map, // server (delivery agent) identity key to pay the delivery fee to serverIdentityKey: string, description = 'MessageBox delivery payment (batch)' @@ -2798,7 +3072,7 @@ export class MessageBoxClient { const deliveryFeeOnce = recipients.reduce((acc, r) => { const q = perRecipientQuotes.get(r) - return (q != null) ? (acc ?? q.deliveryFee) : acc + return q != null ? (acc ?? q.deliveryFee) : acc }, undefined) ?? 0 const senderIdentityKey = await this.getIdentityKey() @@ -2809,11 +3083,14 @@ export class MessageBoxClient { const derivationPrefix = Utils.toBase64(Random(32)) const derivationSuffix = Utils.toBase64(Random(32)) - const { publicKey: agentDerived } = await this.walletClient.getPublicKey({ - protocolID: [2, '3241645161d8'], - keyID: `${derivationPrefix} ${derivationSuffix}`, - counterparty: serverIdentityKey - }, this.originator) + const { publicKey: agentDerived } = await this.walletClient.getPublicKey( + { + protocolID: [2, '3241645161d8'], + keyID: `${derivationPrefix} ${derivationSuffix}`, + counterparty: serverIdentityKey + }, + this.originator + ) const lockingScript = new P2PKH().lock(PublicKey.fromString(agentDerived).toAddress()).toHex() @@ -2841,7 +3118,7 @@ export class MessageBoxClient { for (const r of recipients) { const q = perRecipientQuotes.get(r) - if ((q == null) || q.recipientFee <= 0) continue + if (q == null || q.recipientFee <= 0) continue const derivationPrefix = Utils.toBase64(Random(32)) const derivationSuffix = Utils.toBase64(Random(32)) @@ -2852,7 +3129,9 @@ export class MessageBoxClient { counterparty: r }) - const lockingScript = new P2PKH().lock(PublicKey.fromString(recipientDerived).toAddress()).toHex() + const lockingScript = new P2PKH() + .lock(PublicKey.fromString(recipientDerived).toAddress()) + .toHex() createActionOutputs.push({ satoshis: q.recipientFee, @@ -2876,11 +3155,14 @@ export class MessageBoxClient { }) } - const { tx } = await this.walletClient.createAction({ - description, - outputs: createActionOutputs, - options: { randomizeOutputs: false, acceptDelayedBroadcast: false } - }, this.originator) + const { tx } = await this.walletClient.createAction( + { + description, + outputs: createActionOutputs, + options: { randomizeOutputs: false, acceptDelayedBroadcast: false } + }, + this.originator + ) if (tx == null) throw new Error('Failed to create payment transaction') diff --git a/packages/messaging/message-box-client/src/PeerPayClient.ts b/packages/messaging/message-box-client/src/PeerPayClient.ts index c1fcc2ea4..be8a22be0 100644 --- a/packages/messaging/message-box-client/src/PeerPayClient.ts +++ b/packages/messaging/message-box-client/src/PeerPayClient.ts @@ -11,24 +11,40 @@ */ import { MessageBoxClient } from './MessageBoxClient.js' -import { PeerMessage, PaymentRequestMessage, PaymentRequestResponse, IncomingPaymentRequest, PaymentRequestLimits, DEFAULT_PAYMENT_REQUEST_MIN_AMOUNT, DEFAULT_PAYMENT_REQUEST_MAX_AMOUNT } from './types.js' -import { WalletInterface, AtomicBEEF, AuthFetch, Base64String, OriginatorDomainNameStringUnder250Bytes, Brc29RemittanceModule, createNonce } from '@bsv/sdk' +import { + PeerMessage, + PaymentRequestMessage, + PaymentRequestResponse, + IncomingPaymentRequest, + PaymentRequestLimits, + DEFAULT_PAYMENT_REQUEST_MIN_AMOUNT, + DEFAULT_PAYMENT_REQUEST_MAX_AMOUNT +} from './types.js' +import { + WalletInterface, + AtomicBEEF, + AuthFetch, + Base64String, + OriginatorDomainNameStringUnder250Bytes, + Brc29RemittanceModule, + createNonce +} from '@bsv/sdk' import * as Logger from './Utils/logger.js' -function toNumberArray (tx: AtomicBEEF): number[] { +function toNumberArray(tx: AtomicBEEF): number[] { return Array.isArray(tx) ? tx : Array.from(tx) } -function hexToBytes (hex: string): number[] { +function hexToBytes(hex: string): number[] { const matches = hex.match(/.{1,2}/g) return (matches ?? []).map(byte => Number.parseInt(byte, 16)) } -function safeParse (input: any): T | undefined { +function safeParse(input: any): T | undefined { try { return typeof input === 'string' ? JSON.parse(input) : input - } catch (_parseError) { + } catch { Logger.error('[PP CLIENT] Failed to parse input in safeParse:', input) return undefined } @@ -38,13 +54,17 @@ function safeParse (input: any): T | undefined { * Validates that a parsed object has the required fields for a PaymentRequestMessage. * Returns true for both new requests (has amount, description, expiresAt) and cancellations (has cancelled: true). */ -function isValidPaymentRequestMessage (obj: any): obj is PaymentRequestMessage { +function isValidPaymentRequestMessage(obj: any): obj is PaymentRequestMessage { if (typeof obj !== 'object' || obj === null) return false if (typeof obj.requestId !== 'string') return false if (typeof obj.senderIdentityKey !== 'string') return false if (typeof obj.requestProof !== 'string') return false if (obj.cancelled === true) return true - return typeof obj.amount === 'number' && typeof obj.description === 'string' && typeof obj.expiresAt === 'number' + return ( + typeof obj.amount === 'number' && + typeof obj.description === 'string' && + typeof obj.expiresAt === 'number' + ) } export const STANDARD_PAYMENT_MESSAGEBOX = 'payment_inbox' @@ -103,8 +123,13 @@ export class PeerPayClient extends MessageBoxClient { private readonly messageBox: string private readonly settlementModule: Brc29RemittanceModule - constructor (config: PeerPayClientConfig) { - const { messageBoxHost = 'https://message-box-us-1.bsvb.tech', walletClient, enableLogging = false, originator } = config + constructor(config: PeerPayClientConfig) { + const { + messageBoxHost = 'https://message-box-us-1.bsvb.tech', + walletClient, + enableLogging = false, + originator + } = config // 🔹 Pass enableLogging to MessageBoxClient super({ host: messageBoxHost, walletClient, enableLogging, originator }) @@ -124,8 +149,13 @@ export class PeerPayClient extends MessageBoxClient { }) } - private get authFetchInstance (): AuthFetch { - this._authFetchInstance ??= new AuthFetch(this.peerPayWalletClient, undefined, undefined, this.originator) + private get authFetchInstance(): AuthFetch { + this._authFetchInstance ??= new AuthFetch( + this.peerPayWalletClient, + undefined, + undefined, + this.originator + ) return this._authFetchInstance } @@ -137,7 +167,7 @@ export class PeerPayClient extends MessageBoxClient { * @param {string} params.identityKey - The identity key to allow payment requests from. * @returns {Promise} Resolves when the permission is set. */ - async allowPaymentRequestsFrom ({ identityKey }: { identityKey: string }): Promise { + async allowPaymentRequestsFrom({ identityKey }: { identityKey: string }): Promise { await this.setMessageBoxPermission({ messageBox: PAYMENT_REQUESTS_MESSAGEBOX, sender: identityKey, @@ -153,7 +183,7 @@ export class PeerPayClient extends MessageBoxClient { * @param {string} params.identityKey - The identity key to block payment requests from. * @returns {Promise} Resolves when the permission is set. */ - async blockPaymentRequestsFrom ({ identityKey }: { identityKey: string }): Promise { + async blockPaymentRequestsFrom({ identityKey }: { identityKey: string }): Promise { await this.setMessageBoxPermission({ messageBox: PAYMENT_REQUESTS_MESSAGEBOX, sender: identityKey, @@ -170,8 +200,10 @@ export class PeerPayClient extends MessageBoxClient { * * @returns {Promise>} Resolved with the list of permissions. */ - async listPaymentRequestPermissions (): Promise> { - const permissions = await this.listMessageBoxPermissions({ messageBox: PAYMENT_REQUESTS_MESSAGEBOX }) + async listPaymentRequestPermissions(): Promise> { + const permissions = await this.listMessageBoxPermissions({ + messageBox: PAYMENT_REQUESTS_MESSAGEBOX + }) // Filter to only per-sender entries (sender is not null/empty). // Use the status field returned by the server to determine allowed state. return permissions @@ -194,10 +226,10 @@ export class PeerPayClient extends MessageBoxClient { * @returns {Promise} A valid payment token containing transaction details. * @throws {Error} If the recipient's public key cannot be derived. */ - async createPaymentToken (payment: PaymentParams): Promise { + async createPaymentToken(payment: PaymentParams): Promise { if (payment.amount <= 0) { throw new Error('Invalid payment details: recipient and valid amount are required') - }; + } const result = await this.settlementModule.buildSettlement( { @@ -249,7 +281,7 @@ export class PeerPayClient extends MessageBoxClient { * @returns {Promise} Resolves with the payment result. * @throws {Error} If the recipient is missing or the amount is invalid. */ - async sendPayment (payment: PaymentParams, hostOverride?: string): Promise { + async sendPayment(payment: PaymentParams, hostOverride?: string): Promise { if (payment.recipient == null || payment.recipient.trim() === '' || payment.amount <= 0) { throw new Error('Invalid payment details: recipient and valid amount are required') } @@ -257,11 +289,14 @@ export class PeerPayClient extends MessageBoxClient { const paymentToken = await this.createPaymentToken(payment) // Ensure the recipient is included before sendings - await this.sendMessage({ - recipient: payment.recipient, - messageBox: this.messageBox, - body: JSON.stringify(paymentToken) - }, hostOverride) + await this.sendMessage( + { + recipient: payment.recipient, + messageBox: this.messageBox, + body: JSON.stringify(paymentToken) + }, + hostOverride + ) } /** @@ -278,25 +313,31 @@ export class PeerPayClient extends MessageBoxClient { * @returns {Promise} Resolves when the payment has been sent. * @throws {Error} If payment token generation fails. */ - async sendLivePayment (payment: PaymentParams, overrideHost?: string): Promise { + async sendLivePayment(payment: PaymentParams, overrideHost?: string): Promise { const paymentToken = await this.createPaymentToken(payment) try { // Attempt WebSocket first - await this.sendLiveMessage({ - recipient: payment.recipient, - messageBox: this.messageBox, - body: JSON.stringify(paymentToken) - }, overrideHost) + await this.sendLiveMessage( + { + recipient: payment.recipient, + messageBox: this.messageBox, + body: JSON.stringify(paymentToken) + }, + overrideHost + ) } catch (err) { Logger.warn('[PP CLIENT] sendLiveMessage failed, falling back to HTTP:', err) // Fallback to HTTP if WebSocket fails - await this.sendMessage({ - recipient: payment.recipient, - messageBox: this.messageBox, - body: JSON.stringify(paymentToken) - }, overrideHost) + await this.sendMessage( + { + recipient: payment.recipient, + messageBox: this.messageBox, + body: JSON.stringify(paymentToken) + }, + overrideHost + ) } } @@ -312,7 +353,7 @@ export class PeerPayClient extends MessageBoxClient { * @param {string} [obj.overrideHost] - Optional host override for WebSocket connection. * @returns {Promise} Resolves when the listener is successfully set up. */ - async listenForLivePayments ({ + async listenForLivePayments({ onPayment, overrideHost }: { @@ -350,7 +391,7 @@ export class PeerPayClient extends MessageBoxClient { * @returns {Promise} Resolves with the payment result if successful. * @throws {Error} If payment processing fails. */ - async acceptPayment (payment: IncomingPayment): Promise { + async acceptPayment(payment: IncomingPayment): Promise { try { Logger.log(`[PP CLIENT] Processing payment: ${JSON.stringify(payment, null, 2)}`) @@ -382,7 +423,9 @@ export class PeerPayClient extends MessageBoxClient { const paymentResult = acceptResult.receiptData?.internalizeResult - Logger.log(`[PP CLIENT] Payment internalized successfully: ${JSON.stringify(paymentResult, null, 2)}`) + Logger.log( + `[PP CLIENT] Payment internalized successfully: ${JSON.stringify(paymentResult, null, 2)}` + ) Logger.log(`[PP CLIENT] Acknowledging payment with messageId: ${payment.messageId}`) await this.acknowledgeMessage({ messageIds: [payment.messageId] }) @@ -404,7 +447,7 @@ export class PeerPayClient extends MessageBoxClient { * @param {IncomingPayment} payment - The payment object containing transaction details. * @returns {Promise} Resolves when the payment is either acknowledged or refunded. */ - async rejectPayment (payment: IncomingPayment): Promise { + async rejectPayment(payment: IncomingPayment): Promise { Logger.log(`[PP CLIENT] Rejecting payment: ${JSON.stringify(payment, null, 2)}`) if (payment.token.amount - 1000 < 1000) { @@ -413,7 +456,9 @@ export class PeerPayClient extends MessageBoxClient { try { Logger.log(`[PP CLIENT] Attempting to acknowledge message ${payment.messageId}...`) if (this.authFetch === null || this.authFetch === undefined) { - Logger.warn('[PP CLIENT] Warning: authFetch is undefined! Ensure PeerPayClient is initialized correctly.') + Logger.warn( + '[PP CLIENT] Warning: authFetch is undefined! Ensure PeerPayClient is initialized correctly.' + ) } Logger.log('[PP CLIENT] authFetch instance:', this.authFetch) const response = await this.acknowledgeMessage({ messageIds: [payment.messageId] }) @@ -426,9 +471,13 @@ export class PeerPayClient extends MessageBoxClient { typeof (error as { message: unknown }).message === 'string' && (error as { message: string }).message.includes('401') ) { - Logger.warn(`[PP CLIENT] Authentication issue while acknowledging: ${(error as { message: string }).message}`) + Logger.warn( + `[PP CLIENT] Authentication issue while acknowledging: ${(error as { message: string }).message}` + ) } else { - Logger.error(`[PP CLIENT] Error acknowledging message: ${(error as { message: string }).message}`) + Logger.error( + `[PP CLIENT] Error acknowledging message: ${(error as { message: string }).message}` + ) throw error // Only throw if it's another type of error } } @@ -439,7 +488,9 @@ export class PeerPayClient extends MessageBoxClient { Logger.log('[PP CLIENT] Accepting payment before refunding...') await this.acceptPayment(payment) - Logger.log(`[PP CLIENT] Sending refund of ${payment.token.amount - 1000} to ${payment.sender}...`) + Logger.log( + `[PP CLIENT] Sending refund of ${payment.token.amount - 1000} to ${payment.sender}...` + ) await this.sendPayment({ recipient: payment.sender, amount: payment.token.amount - 1000 // Deduct fee @@ -452,7 +503,9 @@ export class PeerPayClient extends MessageBoxClient { await this.acknowledgeMessage({ messageIds: [payment.messageId] }) Logger.log('[PP CLIENT] Acknowledgment after refund successful.') } catch (error: any) { - Logger.error(`[PP CLIENT] Error acknowledging message after refund: ${(error as { message: string }).message}`) + Logger.error( + `[PP CLIENT] Error acknowledging message after refund: ${(error as { message: string }).message}` + ) } } @@ -465,18 +518,20 @@ export class PeerPayClient extends MessageBoxClient { * @param {string} [overrideHost] - Optional host override to list payments from * @returns {Promise} Resolves with an array of pending payments. */ - async listIncomingPayments (overrideHost?: string): Promise { + async listIncomingPayments(overrideHost?: string): Promise { const messages = await this.listMessages({ messageBox: this.messageBox, host: overrideHost }) - return messages.map((msg: any) => { - const parsedToken = safeParse(msg.body) - if (parsedToken == null) return null - - return { - messageId: msg.messageId, - sender: msg.sender, - token: parsedToken - } - }).filter((p): p is IncomingPayment => p != null) + return messages + .map((msg: any) => { + const parsedToken = safeParse(msg.body) + if (parsedToken == null) return null + + return { + messageId: msg.messageId, + sender: msg.sender, + token: parsedToken + } + }) + .filter((p): p is IncomingPayment => p != null) } /** @@ -487,9 +542,13 @@ export class PeerPayClient extends MessageBoxClient { * @param {string} [hostOverride] - Optional host override for the message box server. * @returns {Promise} Resolves with an array of payment request responses. */ - async listPaymentRequestResponses (hostOverride?: string): Promise { - const messages = await this.listMessages({ messageBox: PAYMENT_REQUEST_RESPONSES_MESSAGEBOX, host: hostOverride }) - return messages.map((msg: any) => safeParse(msg.body)) + async listPaymentRequestResponses(hostOverride?: string): Promise { + const messages = await this.listMessages({ + messageBox: PAYMENT_REQUEST_RESPONSES_MESSAGEBOX, + host: hostOverride + }) + return messages + .map((msg: any) => safeParse(msg.body)) .filter((r): r is PaymentRequestResponse => r != null) } @@ -504,7 +563,7 @@ export class PeerPayClient extends MessageBoxClient { * @param {string} [params.overrideHost] - Optional host override for the WebSocket connection. * @returns {Promise} Resolves when the listener is established. */ - async listenForLivePaymentRequests ({ + async listenForLivePaymentRequests({ onRequest, overrideHost }: { @@ -541,7 +600,7 @@ export class PeerPayClient extends MessageBoxClient { * @param {string} [params.overrideHost] - Optional host override for the WebSocket connection. * @returns {Promise} Resolves when the listener is established. */ - async listenForLivePaymentRequestResponses ({ + async listenForLivePaymentRequestResponses({ onResponse, overrideHost }: { @@ -570,8 +629,8 @@ export class PeerPayClient extends MessageBoxClient { * @param {string} [hostOverride] - Optional host override for the message box server. * @returns {Promise} Resolves when payment is sent and acknowledgment is complete. */ - async fulfillPaymentRequest ( - params: { request: IncomingPaymentRequest, note?: string }, + async fulfillPaymentRequest( + params: { request: IncomingPaymentRequest; note?: string }, hostOverride?: string ): Promise { const { request, note } = params @@ -585,11 +644,14 @@ export class PeerPayClient extends MessageBoxClient { ...(note != null && { note }) } - await this.sendMessage({ - recipient: request.sender, - messageBox: PAYMENT_REQUEST_RESPONSES_MESSAGEBOX, - body: JSON.stringify(response) - }, hostOverride) + await this.sendMessage( + { + recipient: request.sender, + messageBox: PAYMENT_REQUEST_RESPONSES_MESSAGEBOX, + body: JSON.stringify(response) + }, + hostOverride + ) await this.acknowledgeMessage({ messageIds: [request.messageId], host: hostOverride }) } @@ -604,8 +666,8 @@ export class PeerPayClient extends MessageBoxClient { * @param {string} [hostOverride] - Optional host override for the message box server. * @returns {Promise} Resolves when the response is sent and request is acknowledged. */ - async declinePaymentRequest ( - params: { request: IncomingPaymentRequest, note?: string }, + async declinePaymentRequest( + params: { request: IncomingPaymentRequest; note?: string }, hostOverride?: string ): Promise { const { request, note } = params @@ -616,11 +678,14 @@ export class PeerPayClient extends MessageBoxClient { ...(note != null && { note }) } - await this.sendMessage({ - recipient: request.sender, - messageBox: PAYMENT_REQUEST_RESPONSES_MESSAGEBOX, - body: JSON.stringify(response) - }, hostOverride) + await this.sendMessage( + { + recipient: request.sender, + messageBox: PAYMENT_REQUEST_RESPONSES_MESSAGEBOX, + body: JSON.stringify(response) + }, + hostOverride + ) await this.acknowledgeMessage({ messageIds: [request.messageId], host: hostOverride }) } @@ -640,10 +705,10 @@ export class PeerPayClient extends MessageBoxClient { * @returns {Promise<{ requestId: string }>} The generated requestId for this request. * @throws {Error} If amount is <= 0. */ - async requestPayment ( - params: { recipient: string, amount: number, description: string, expiresAt: number }, + async requestPayment( + params: { recipient: string; amount: number; description: string; expiresAt: number }, hostOverride?: string - ): Promise<{ requestId: string, requestProof: string }> { + ): Promise<{ requestId: string; requestProof: string }> { if (params.amount <= 0) { throw new Error('Invalid payment request: amount must be greater than 0') } @@ -652,13 +717,18 @@ export class PeerPayClient extends MessageBoxClient { const senderIdentityKey = await this.getIdentityKey() const proofData = Array.from(new TextEncoder().encode(requestId + params.recipient)) - const { hmac } = await this.peerPayWalletClient.createHmac({ - data: proofData, - protocolID: [2, 'payment request auth'], - keyID: requestId, - counterparty: params.recipient - }, this.originator) - const requestProof = Array.from(hmac).map(b => b.toString(16).padStart(2, '0')).join('') + const { hmac } = await this.peerPayWalletClient.createHmac( + { + data: proofData, + protocolID: [2, 'payment request auth'], + keyID: requestId, + counterparty: params.recipient + }, + this.originator + ) + const requestProof = Array.from(hmac) + .map(b => b.toString(16).padStart(2, '0')) + .join('') const body: PaymentRequestMessage = { requestId, @@ -670,15 +740,18 @@ export class PeerPayClient extends MessageBoxClient { } try { - await this.sendMessage({ - recipient: params.recipient, - messageBox: PAYMENT_REQUESTS_MESSAGEBOX, - body: JSON.stringify(body) - }, hostOverride) + await this.sendMessage( + { + recipient: params.recipient, + messageBox: PAYMENT_REQUESTS_MESSAGEBOX, + body: JSON.stringify(body) + }, + hostOverride + ) } catch (err: any) { // Translate HTTP 403 (permission denied) into a user-friendly message. if (typeof err?.message === 'string' && err.message.includes('403')) { - throw new Error('Payment request blocked — you are not on the recipient\'s whitelist.') + throw new Error("Payment request blocked — you are not on the recipient's whitelist.") } throw err } @@ -699,17 +772,20 @@ export class PeerPayClient extends MessageBoxClient { * @param {PaymentRequestLimits} [limits] - Optional min/max satoshi limits for filtering. * @returns {Promise} Resolves with active, valid payment requests. */ - async listIncomingPaymentRequests ( + async listIncomingPaymentRequests( hostOverride?: string, limits?: PaymentRequestLimits ): Promise { - const messages = await this.listMessages({ messageBox: PAYMENT_REQUESTS_MESSAGEBOX, host: hostOverride }) + const messages = await this.listMessages({ + messageBox: PAYMENT_REQUESTS_MESSAGEBOX, + host: hostOverride + }) const myIdentityKey = await this.getIdentityKey() const now = Date.now() // Parse and validate all messages, collecting malformed ones for ack const malformedMessageIds: string[] = [] - const parsed: Array<{ messageId: string, sender: string, body: PaymentRequestMessage }> = [] + const parsed: Array<{ messageId: string; sender: string; body: PaymentRequestMessage }> = [] for (const msg of messages) { const body = safeParse(msg.body) @@ -727,18 +803,25 @@ export class PeerPayClient extends MessageBoxClient { if (item.body.cancelled === true) { // Verify cancellation HMAC proof try { - const proofData = Array.from(new TextEncoder().encode(item.body.requestId + myIdentityKey)) - await this.peerPayWalletClient.verifyHmac({ - data: proofData, - hmac: hexToBytes(item.body.requestProof), - protocolID: [2, 'payment request auth'], - keyID: item.body.requestId, - counterparty: item.sender - }, this.originator) + const proofData = Array.from( + new TextEncoder().encode(item.body.requestId + myIdentityKey) + ) + await this.peerPayWalletClient.verifyHmac( + { + data: proofData, + hmac: hexToBytes(item.body.requestProof), + protocolID: [2, 'payment request auth'], + keyID: item.body.requestId, + counterparty: item.sender + }, + this.originator + ) cancelledRequests.set(item.body.requestId, item.sender) cancelMessageIds.push(item.messageId) } catch { - Logger.warn(`[PP CLIENT] Invalid cancellation proof for requestId=${item.body.requestId}, discarding`) + Logger.warn( + `[PP CLIENT] Invalid cancellation proof for requestId=${item.body.requestId}, discarding` + ) malformedMessageIds.push(item.messageId) } } @@ -778,13 +861,16 @@ export class PeerPayClient extends MessageBoxClient { // Verify HMAC proof — ensures message came from claimed sender try { const proofData = Array.from(new TextEncoder().encode(requestId + myIdentityKey)) - await this.peerPayWalletClient.verifyHmac({ - data: proofData, - hmac: hexToBytes(item.body.requestProof), - protocolID: [2, 'payment request auth'], - keyID: requestId, - counterparty: item.sender - }, this.originator) + await this.peerPayWalletClient.verifyHmac( + { + data: proofData, + hmac: hexToBytes(item.body.requestProof), + protocolID: [2, 'payment request auth'], + keyID: requestId, + counterparty: item.sender + }, + this.originator + ) } catch { Logger.warn(`[PP CLIENT] Invalid requestProof for requestId=${requestId}, discarding`) malformedMessageIds.push(item.messageId) @@ -835,8 +921,8 @@ export class PeerPayClient extends MessageBoxClient { * @param {string} [hostOverride] - Optional host override for the message box server. * @returns {Promise} Resolves when the cancellation message has been sent. */ - async cancelPaymentRequest ( - params: { recipient: string, requestId: string, requestProof: string }, + async cancelPaymentRequest( + params: { recipient: string; requestId: string; requestProof: string }, hostOverride?: string ): Promise { const senderIdentityKey = await this.getIdentityKey() @@ -848,10 +934,13 @@ export class PeerPayClient extends MessageBoxClient { cancelled: true } - await this.sendMessage({ - recipient: params.recipient, - messageBox: PAYMENT_REQUESTS_MESSAGEBOX, - body: JSON.stringify(body) - }, hostOverride) + await this.sendMessage( + { + recipient: params.recipient, + messageBox: PAYMENT_REQUESTS_MESSAGEBOX, + body: JSON.stringify(body) + }, + hostOverride + ) } } diff --git a/packages/messaging/message-box-client/src/PeerTokenClient.ts b/packages/messaging/message-box-client/src/PeerTokenClient.ts index 2a66260f7..996df0cd9 100644 --- a/packages/messaging/message-box-client/src/PeerTokenClient.ts +++ b/packages/messaging/message-box-client/src/PeerTokenClient.ts @@ -11,24 +11,32 @@ */ import { MessageBoxClient } from './MessageBoxClient.js' import { - PeerMessage, TokenToken, IncomingToken, TokenRequestMessage, TokenRequestResponse, + PeerMessage, + TokenToken, + IncomingToken, + TokenRequestMessage, + TokenRequestResponse, IncomingTokenRequest } from './types.js' import { - TokenSettlementAdapter, TokenSourceRef, TokenAdapterContext + TokenSettlementAdapter, + TokenSourceRef, + TokenAdapterContext } from './TokenSettlementAdapter.js' import { WalletInterface, OriginatorDomainNameStringUnder250Bytes, createNonce } from '@bsv/sdk' import * as Logger from './Utils/logger.js' -function hexToBytes (hex: string): number[] { - const matches = hex.match(/.{1,2}/g) - return (matches ?? []).map(byte => Number.parseInt(byte, 16)) +function hexToBytes(hex: string): number[] { + if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) { + throw new TypeError('HMAC proof must be a non-empty, even-length hexadecimal string') + } + return hex.match(/.{2}/g)!.map(byte => Number.parseInt(byte, 16)) } -function safeParse (input: any): T | undefined { +function safeParse(input: unknown): T | undefined { try { - return typeof input === 'string' ? JSON.parse(input) : input + return typeof input === 'string' ? (JSON.parse(input) as T) : (input as T) } catch (parseError) { Logger.error('[PT CLIENT] Failed to parse input in safeParse:', input, parseError) return undefined @@ -72,8 +80,13 @@ export class PeerTokenClient extends MessageBoxClient { */ private readonly tokenHost?: string - constructor (config: PeerTokenClientConfig) { - const { messageBoxHost = 'https://message-box-us-1.bsvb.tech', walletClient, enableLogging = false, originator } = config + constructor(config: PeerTokenClientConfig) { + const { + messageBoxHost = 'https://message-box-us-1.bsvb.tech', + walletClient, + enableLogging = false, + originator + } = config super({ host: messageBoxHost, walletClient, enableLogging, originator }) this.messageBox = config.messageBox ?? STANDARD_TOKEN_MESSAGEBOX @@ -83,7 +96,7 @@ export class PeerTokenClient extends MessageBoxClient { this.adapters = new Map(config.adapters.map(a => [a.protocol, a])) } - private adapterFor (protocol: string): TokenSettlementAdapter { + private adapterFor(protocol: string): TokenSettlementAdapter { const adapter = this.adapters.get(protocol) if (adapter == null) { throw new Error(`No token settlement adapter registered for protocol '${protocol}'`) @@ -91,8 +104,13 @@ export class PeerTokenClient extends MessageBoxClient { return adapter } - private adapterContext (dryRun = false): TokenAdapterContext { - return { wallet: this.peerTokenWalletClient, originator: this.originator, logger: Logger, dryRun } + private adapterContext(dryRun = false): TokenAdapterContext { + return { + wallet: this.peerTokenWalletClient, + originator: this.originator, + logger: Logger, + dryRun + } } /** @@ -100,7 +118,7 @@ export class PeerTokenClient extends MessageBoxClient { * adapter for the requested protocol. With `dryRun`, the adapter derives and * validates only — no signing, no broadcast (mainnet rehearsal). */ - async createTokenToken (params: SendTokenParams, dryRun = false): Promise { + async createTokenToken(params: SendTokenParams, dryRun = false): Promise { const adapter = this.adapterFor(params.protocol) const result = await adapter.buildTokenSettlement( { recipient: params.recipient, source: params.source, amount: params.amount }, @@ -122,42 +140,51 @@ export class PeerTokenClient extends MessageBoxClient { } /** Sends a token to a recipient over HTTP. Returns the sent token (incl. txid). */ - async sendToken (params: SendTokenParams, hostOverride?: string): Promise { + async sendToken(params: SendTokenParams, hostOverride?: string): Promise { if (params.recipient == null || params.recipient.trim() === '') { throw new Error('Invalid token transfer: recipient is required') } const token = await this.createTokenToken(params) - await this.sendMessage({ - recipient: params.recipient, - messageBox: this.messageBox, - body: JSON.stringify(token) - }, hostOverride ?? this.tokenHost) + await this.sendMessage( + { + recipient: params.recipient, + messageBox: this.messageBox, + body: JSON.stringify(token) + }, + hostOverride ?? this.tokenHost + ) return token } /** Sends a token over WebSocket, falling back to HTTP if the socket fails. Returns the sent token. */ - async sendLiveToken (params: SendTokenParams, overrideHost?: string): Promise { + async sendLiveToken(params: SendTokenParams, overrideHost?: string): Promise { const token = await this.createTokenToken(params) const host = overrideHost ?? this.tokenHost try { - await this.sendLiveMessage({ - recipient: params.recipient, - messageBox: this.messageBox, - body: JSON.stringify(token) - }, host) + await this.sendLiveMessage( + { + recipient: params.recipient, + messageBox: this.messageBox, + body: JSON.stringify(token) + }, + host + ) } catch (err) { Logger.warn('[PT CLIENT] sendLiveMessage failed, falling back to HTTP:', err) - await this.sendMessage({ - recipient: params.recipient, - messageBox: this.messageBox, - body: JSON.stringify(token) - }, host) + await this.sendMessage( + { + recipient: params.recipient, + messageBox: this.messageBox, + body: JSON.stringify(token) + }, + host + ) } return token } /** Listens for incoming tokens in real time over WebSocket. */ - async listenForLiveTokens ({ + async listenForLiveTokens({ onToken, overrideHost }: { @@ -179,7 +206,7 @@ export class PeerTokenClient extends MessageBoxClient { * Accepts an incoming token by delegating to the adapter for its protocol, * then acknowledges the transport message. */ - async acceptToken (incoming: IncomingToken): Promise { + async acceptToken(incoming: IncomingToken): Promise { try { const adapter = this.adapterFor(incoming.token.protocol) const result = await adapter.acceptTokenSettlement( @@ -208,15 +235,20 @@ export class PeerTokenClient extends MessageBoxClient { } /** Lists pending incoming tokens from the token message box. */ - async listIncomingTokens (overrideHost?: string): Promise { + async listIncomingTokens(overrideHost?: string): Promise { // listMessagesLite talks to the host directly and skips overlay (SLAP) // resolution, which has no advertised ls_messagebox hosts on mainnet. - const messages = await this.listMessagesLite({ messageBox: this.messageBox, host: overrideHost ?? this.tokenHost }) - return messages.map((msg: any) => { - const token = safeParse(msg.body) - if (token == null) return null - return { messageId: msg.messageId, sender: msg.sender, token } - }).filter((t): t is IncomingToken => t != null) + const messages = await this.listMessagesLite({ + messageBox: this.messageBox, + host: overrideHost ?? this.tokenHost + }) + return messages + .map((msg: any) => { + const token = safeParse(msg.body) + if (token == null) return null + return { messageId: msg.messageId, sender: msg.sender, token } + }) + .filter((t): t is IncomingToken => t != null) } // ── Token request flow (mirrors PeerPayClient's payment requests) ────────── @@ -225,21 +257,33 @@ export class PeerTokenClient extends MessageBoxClient { * Requests a token transfer from a payer. Generates a unique requestId and an * HMAC proof tying the request to the sender, then posts it to the requests box. */ - async requestToken ( - params: { recipient: string, protocol: string, assetId: string, amount: string, description: string, expiresAt: number }, + async requestToken( + params: { + recipient: string + protocol: string + assetId: string + amount: string + description: string + expiresAt: number + }, hostOverride?: string - ): Promise<{ requestId: string, requestProof: string }> { + ): Promise<{ requestId: string; requestProof: string }> { const requestId = await createNonce(this.peerTokenWalletClient, 'self', this.originator) const senderIdentityKey = await this.getIdentityKey() const proofData = Array.from(new TextEncoder().encode(requestId + params.recipient)) - const { hmac } = await this.peerTokenWalletClient.createHmac({ - data: proofData, - protocolID: [2, 'token request auth'], - keyID: requestId, - counterparty: params.recipient - }, this.originator) - const requestProof = Array.from(hmac).map((b: number) => b.toString(16).padStart(2, '0')).join('') + const { hmac } = await this.peerTokenWalletClient.createHmac( + { + data: proofData, + protocolID: [2, 'token request auth'], + keyID: requestId, + counterparty: params.recipient + }, + this.originator + ) + const requestProof = Array.from(hmac) + .map((b: number) => b.toString(16).padStart(2, '0')) + .join('') const body: TokenRequestMessage = { requestId, @@ -252,17 +296,20 @@ export class PeerTokenClient extends MessageBoxClient { requestProof } - await this.sendMessage({ - recipient: params.recipient, - messageBox: TOKEN_REQUESTS_MESSAGEBOX, - body: JSON.stringify(body) - }, hostOverride ?? this.tokenHost) + await this.sendMessage( + { + recipient: params.recipient, + messageBox: TOKEN_REQUESTS_MESSAGEBOX, + body: JSON.stringify(body) + }, + hostOverride ?? this.tokenHost + ) return { requestId, requestProof } } /** Listens for incoming token requests in real time over WebSocket. */ - async listenForLiveTokenRequests ({ + async listenForLiveTokenRequests({ onRequest, overrideHost }: { @@ -293,18 +340,21 @@ export class PeerTokenClient extends MessageBoxClient { * Fulfills an incoming token request by sending the requested token and * notifying the requester with a 'sent' response. Acknowledges the request. */ - async fulfillTokenRequest ( - params: { request: IncomingTokenRequest, source: TokenSourceRef, note?: string }, + async fulfillTokenRequest( + params: { request: IncomingTokenRequest; source: TokenSourceRef; note?: string }, hostOverride?: string ): Promise { const { request, source, note } = params - await this.sendToken({ - recipient: request.sender, - protocol: request.protocol, - source, - amount: request.amount - }, hostOverride ?? this.tokenHost) + await this.sendToken( + { + recipient: request.sender, + protocol: request.protocol, + source, + amount: request.amount + }, + hostOverride ?? this.tokenHost + ) const response: TokenRequestResponse = { requestId: request.requestId, @@ -315,18 +365,24 @@ export class PeerTokenClient extends MessageBoxClient { ...(note != null && { note }) } - await this.sendMessage({ - recipient: request.sender, - messageBox: TOKEN_REQUEST_RESPONSES_MESSAGEBOX, - body: JSON.stringify(response) - }, hostOverride ?? this.tokenHost) + await this.sendMessage( + { + recipient: request.sender, + messageBox: TOKEN_REQUEST_RESPONSES_MESSAGEBOX, + body: JSON.stringify(response) + }, + hostOverride ?? this.tokenHost + ) - await this.acknowledgeMessage({ messageIds: [request.messageId], host: hostOverride ?? this.tokenHost }) + await this.acknowledgeMessage({ + messageIds: [request.messageId], + host: hostOverride ?? this.tokenHost + }) } /** Declines an incoming token request and acknowledges it. */ - async declineTokenRequest ( - params: { request: IncomingTokenRequest, note?: string }, + async declineTokenRequest( + params: { request: IncomingTokenRequest; note?: string }, hostOverride?: string ): Promise { const { request, note } = params @@ -335,17 +391,23 @@ export class PeerTokenClient extends MessageBoxClient { status: 'declined', ...(note != null && { note }) } - await this.sendMessage({ - recipient: request.sender, - messageBox: TOKEN_REQUEST_RESPONSES_MESSAGEBOX, - body: JSON.stringify(response) - }, hostOverride ?? this.tokenHost) - await this.acknowledgeMessage({ messageIds: [request.messageId], host: hostOverride ?? this.tokenHost }) + await this.sendMessage( + { + recipient: request.sender, + messageBox: TOKEN_REQUEST_RESPONSES_MESSAGEBOX, + body: JSON.stringify(response) + }, + hostOverride ?? this.tokenHost + ) + await this.acknowledgeMessage({ + messageIds: [request.messageId], + host: hostOverride ?? this.tokenHost + }) } /** Cancels a previously sent token request. */ - async cancelTokenRequest ( - params: { recipient: string, requestId: string, requestProof: string }, + async cancelTokenRequest( + params: { recipient: string; requestId: string; requestProof: string }, hostOverride?: string ): Promise { const senderIdentityKey = await this.getIdentityKey() @@ -355,17 +417,24 @@ export class PeerTokenClient extends MessageBoxClient { requestProof: params.requestProof, cancelled: true } - await this.sendMessage({ - recipient: params.recipient, - messageBox: TOKEN_REQUESTS_MESSAGEBOX, - body: JSON.stringify(body) - }, hostOverride ?? this.tokenHost) + await this.sendMessage( + { + recipient: params.recipient, + messageBox: TOKEN_REQUESTS_MESSAGEBOX, + body: JSON.stringify(body) + }, + hostOverride ?? this.tokenHost + ) } /** Lists responses to token requests this client has sent. */ - async listTokenRequestResponses (hostOverride?: string): Promise { - const messages = await this.listMessagesLite({ messageBox: TOKEN_REQUEST_RESPONSES_MESSAGEBOX, host: hostOverride ?? this.tokenHost }) - return messages.map((msg: any) => safeParse(msg.body)) + async listTokenRequestResponses(hostOverride?: string): Promise { + const messages = await this.listMessagesLite({ + messageBox: TOKEN_REQUEST_RESPONSES_MESSAGEBOX, + host: hostOverride ?? this.tokenHost + }) + return messages + .map((msg: any) => safeParse(msg.body)) .filter((r): r is TokenRequestResponse => r != null) } @@ -373,17 +442,24 @@ export class PeerTokenClient extends MessageBoxClient { * Verifies the HMAC proof on an incoming token request, confirming it came * from the claimed sender. Mirrors PeerPayClient's request-proof check. */ - async verifyTokenRequestProof (request: { requestId: string, sender: string, requestProof: string }): Promise { + async verifyTokenRequestProof(request: { + requestId: string + sender: string + requestProof: string + }): Promise { const myIdentityKey = await this.getIdentityKey() try { const proofData = Array.from(new TextEncoder().encode(request.requestId + myIdentityKey)) - await this.peerTokenWalletClient.verifyHmac({ - data: proofData, - hmac: hexToBytes(request.requestProof), - protocolID: [2, 'token request auth'], - keyID: request.requestId, - counterparty: request.sender - }, this.originator) + await this.peerTokenWalletClient.verifyHmac( + { + data: proofData, + hmac: hexToBytes(request.requestProof), + protocolID: [2, 'token request auth'], + keyID: request.requestId, + counterparty: request.sender + }, + this.originator + ) return true } catch { return false diff --git a/packages/messaging/message-box-client/src/RemittanceAdapter.ts b/packages/messaging/message-box-client/src/RemittanceAdapter.ts index 2baac0beb..84d064414 100644 --- a/packages/messaging/message-box-client/src/RemittanceAdapter.ts +++ b/packages/messaging/message-box-client/src/RemittanceAdapter.ts @@ -29,10 +29,7 @@ */ import { PubKeyHex } from '@bsv/sdk' -import type { - CommsLayer as SdkCommsLayer, - PeerMessage as SdkRemittancePeerMessage -} from '@bsv/sdk' +import type { CommsLayer as SdkCommsLayer, PeerMessage as SdkRemittancePeerMessage } from '@bsv/sdk' import type { MessageBoxClient } from './MessageBoxClient.js' import type { PeerMessage as MessageBoxPeerMessage } from './types.js' @@ -51,7 +48,7 @@ export class RemittanceAdapter implements SdkCommsLayer { * Creates a new RemittanceAdapter * @param messageBox - The MessageBoxClient instance to adapt */ - constructor(private readonly messageBox: MessageBoxClient) { } + constructor(private readonly messageBox: MessageBoxClient) {} /** * Sends a message over the store-and-forward channel @@ -60,14 +57,17 @@ export class RemittanceAdapter implements SdkCommsLayer { * @returns The transport message ID */ async sendMessage( - args: { recipient: PubKeyHex, messageBox: string, body: string }, + args: { recipient: PubKeyHex; messageBox: string; body: string }, hostOverride?: string ): Promise { - const result = await this.messageBox.sendMessage({ - recipient: args.recipient, - messageBox: args.messageBox, - body: args.body - }, hostOverride) + const result = await this.messageBox.sendMessage( + { + recipient: args.recipient, + messageBox: args.messageBox, + body: args.body + }, + hostOverride + ) return result.messageId } @@ -80,14 +80,17 @@ export class RemittanceAdapter implements SdkCommsLayer { * @returns The transport message ID */ async sendLiveMessage( - args: { recipient: PubKeyHex, messageBox: string, body: string }, + args: { recipient: PubKeyHex; messageBox: string; body: string }, hostOverride?: string ): Promise { - const result = await this.messageBox.sendLiveMessage({ - recipient: args.recipient, - messageBox: args.messageBox, - body: args.body - }, hostOverride) + const result = await this.messageBox.sendLiveMessage( + { + recipient: args.recipient, + messageBox: args.messageBox, + body: args.body + }, + hostOverride + ) return result.messageId } @@ -101,7 +104,10 @@ export class RemittanceAdapter implements SdkCommsLayer { * @param args - List parameters (messageBox, optional host) * @returns Array of peer messages with stringified bodies */ - async listMessages(args: { messageBox: string, host?: string }): Promise { + async listMessages(args: { + messageBox: string + host?: string + }): Promise { const defaultRecipient = await this.messageBox.getIdentityKey() const messages = await this.messageBox.listMessages({ messageBox: args.messageBox, @@ -139,8 +145,8 @@ export class RemittanceAdapter implements SdkCommsLayer { }) } - private toRemittancePeerMessage ( - msg: MessageBoxPeerMessage & { recipient?: string, messageBox?: string }, + private toRemittancePeerMessage( + msg: MessageBoxPeerMessage & { recipient?: string; messageBox?: string }, fallbackMessageBox: string, fallbackRecipient: PubKeyHex ): RemittancePeerMessage { @@ -153,7 +159,7 @@ export class RemittanceAdapter implements SdkCommsLayer { } } - private toBodyString (body: unknown): string { + private toBodyString(body: unknown): string { if (typeof body === 'string') return body try { return JSON.stringify(body) ?? '' diff --git a/packages/messaging/message-box-client/src/TokenSettlementAdapter.ts b/packages/messaging/message-box-client/src/TokenSettlementAdapter.ts index 85644db3e..cd1064586 100644 --- a/packages/messaging/message-box-client/src/TokenSettlementAdapter.ts +++ b/packages/messaging/message-box-client/src/TokenSettlementAdapter.ts @@ -26,7 +26,7 @@ export interface TokenSourceRef { /** The transferable result produced by an adapter; travels in the message body. */ export interface TokenSettlementArtifact { - customInstructions: { derivationPrefix: Base64String, derivationSuffix: Base64String } + customInstructions: { derivationPrefix: Base64String; derivationSuffix: Base64String } transaction: AtomicBEEF protocol: string assetId: string @@ -65,19 +65,28 @@ export interface AcceptTokenSettlementArgs { settlement: TokenSettlementArtifact } -export interface Termination { code: string, message: string } +export interface Termination { + code: string + message: string +} export type TokenBuildResult = - | { action: 'settle', artifact: TokenSettlementArtifact } - | { action: 'terminate', termination: Termination } + | { action: 'settle'; artifact: TokenSettlementArtifact } + | { action: 'terminate'; termination: Termination } export type TokenAcceptResult = - | { action: 'accept', receiptData?: { internalizeResult?: unknown } } - | { action: 'terminate', termination: Termination } + | { action: 'accept'; receiptData?: { internalizeResult?: unknown } } + | { action: 'terminate'; termination: Termination } export interface TokenSettlementAdapter { /** Discriminator matched against TokenSourceRef.protocol / TokenToken.protocol. */ readonly protocol: string - buildTokenSettlement: (args: BuildTokenSettlementArgs, ctx: TokenAdapterContext) => Promise - acceptTokenSettlement: (args: AcceptTokenSettlementArgs, ctx: TokenAdapterContext) => Promise + buildTokenSettlement: ( + args: BuildTokenSettlementArgs, + ctx: TokenAdapterContext + ) => Promise + acceptTokenSettlement: ( + args: AcceptTokenSettlementArgs, + ctx: TokenAdapterContext + ) => Promise } diff --git a/packages/messaging/message-box-client/src/Utils/logger.ts b/packages/messaging/message-box-client/src/Utils/logger.ts index 705d86ba7..8b99fe3bc 100644 --- a/packages/messaging/message-box-client/src/Utils/logger.ts +++ b/packages/messaging/message-box-client/src/Utils/logger.ts @@ -1,25 +1,25 @@ let isEnabled = false -export function enable (): void { +export function enable(): void { isEnabled = true } -export function disable (): void { +export function disable(): void { isEnabled = false } -export function log (...args: unknown[]): void { +export function log(...args: unknown[]): void { if (isEnabled) { console.log(...args) } } -export function warn (...args: unknown[]): void { +export function warn(...args: unknown[]): void { if (isEnabled) { console.warn(...args) } } -export function error (...args: unknown[]): void { +export function error(...args: unknown[]): void { console.error(...args) } diff --git a/packages/messaging/message-box-client/src/__tests/MessageBoxClient.test.ts b/packages/messaging/message-box-client/src/__tests/MessageBoxClient.test.ts index 4dddce538..a2e3e89ae 100644 --- a/packages/messaging/message-box-client/src/__tests/MessageBoxClient.test.ts +++ b/packages/messaging/message-box-client/src/__tests/MessageBoxClient.test.ts @@ -1,5 +1,13 @@ /* eslint-env jest */ -import { WalletClient, AuthFetch, Transaction, LockingScript, PushDrop, TopicBroadcaster, Beef } from '@bsv/sdk' +import { + WalletClient, + AuthFetch, + Transaction, + LockingScript, + PushDrop, + TopicBroadcaster, + Beef +} from '@bsv/sdk' import { jest } from '@jest/globals' // MOCK: WebSocket behavior @@ -44,7 +52,7 @@ jest.spyOn(WalletClient.prototype, 'createSignature').mockResolvedValue({ jest.spyOn(WalletClient.prototype, 'connectToSubstrate').mockImplementation(async function () { this.substrate = { - createSignature: async (message: Uint8Array) => { + createSignature: async (_message: Uint8Array) => { return Array.from(new Uint8Array([1, 2, 3])) // Return mock signature } } @@ -75,11 +83,11 @@ jest.spyOn(MessageBoxClient.prototype as any, 'anointHost').mockImplementation(a return { txid: 'mocked-anoint-txid' } }) -jest.spyOn(MessageBoxClient.prototype as any, 'queryAdvertisements') +jest + .spyOn(MessageBoxClient.prototype as any, 'queryAdvertisements') .mockResolvedValue([] as string[]) -jest.spyOn(AuthFetch.prototype, 'fetch') - .mockResolvedValue(defaultMockResponse as Response) +jest.spyOn(AuthFetch.prototype, 'fetch').mockResolvedValue(defaultMockResponse as Response) // Optional: Global WebSocket override (not strictly needed with AuthSocketClient) class MockWebSocket { @@ -168,21 +176,28 @@ describe('MessageBoxClient', () => { await messageBoxClient.init() // Bypass the real connection logic - jest.spyOn(messageBoxClient, 'initializeConnection').mockImplementation(async () => { }) + const initializeConnection = jest + .spyOn(messageBoxClient, 'initializeConnection') + .mockImplementation(async () => {}) - // Manually set identity key - ; (messageBoxClient as any).myIdentityKey = '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' + // Manually set identity key + ;(messageBoxClient as any).myIdentityKey = + '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' - // Simulate WebSocket not initialized - ; (messageBoxClient as any).socket = null + // Simulate WebSocket not initialized + ;(messageBoxClient as any).socket = null // Expect it to fall back to HTTP and succeed - const result = await messageBoxClient.sendLiveMessage({ - recipient: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4', - messageBox: 'test_inbox', - body: 'Test message' - }) + const result = await messageBoxClient.sendLiveMessage( + { + recipient: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4', + messageBox: 'test_inbox', + body: 'Test message' + }, + 'https://override.example' + ) + expect(initializeConnection).toHaveBeenCalledWith('https://override.example') expect(result).toEqual({ status: 'success', message: 'Mocked response', @@ -249,7 +264,9 @@ describe('MessageBoxClient', () => { // Simulate WebSocket acknowledgment setTimeout(() => { - socketOnMap['sendMessageAck-02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4-test_inbox']?.({ + socketOnMap[ + 'sendMessageAck-02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4-test_inbox' + ]?.({ status: 'success', messageId: 'mocked123' }) @@ -284,7 +301,8 @@ describe('MessageBoxClient', () => { enableLogging: true }) await messageBoxClient.init() - ; (messageBoxClient as any).myIdentityKey = '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' + ;(messageBoxClient as any).myIdentityKey = + '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' jest.spyOn(messageBoxClient.authFetch, 'fetch').mockResolvedValue({ json: async () => ({ status: 'success', @@ -311,7 +329,8 @@ describe('MessageBoxClient', () => { enableLogging: true }) await messageBoxClient.init() - ; (messageBoxClient as any).myIdentityKey = '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' + ;(messageBoxClient as any).myIdentityKey = + '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' jest.spyOn(messageBoxClient.authFetch, 'fetch').mockResolvedValue({ json: async () => JSON.parse(VALID_LIST_AND_READ_RESULT.body), @@ -332,7 +351,8 @@ describe('MessageBoxClient', () => { enableLogging: true }) await messageBoxClient.init() - ; (messageBoxClient as any).myIdentityKey = '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' + ;(messageBoxClient as any).myIdentityKey = + '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' jest.spyOn(messageBoxClient.authFetch, 'fetch').mockResolvedValue({ json: async () => JSON.parse(VALID_ACK_RESULT.body), @@ -354,26 +374,32 @@ describe('MessageBoxClient', () => { }) await messageBoxClient.init() - ; (messageBoxClient as any).myIdentityKey = '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' + ;(messageBoxClient as any).myIdentityKey = + '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' - jest.spyOn(messageBoxClient.authFetch, 'fetch') - .mockResolvedValue({ - status: 500, - statusText: 'Internal Server Error', - ok: false, - json: async () => ({ status: 'error', description: 'Internal Server Error' }), - headers: new Headers() - } as unknown as Response) + jest.spyOn(messageBoxClient.authFetch, 'fetch').mockResolvedValue({ + status: 500, + statusText: 'Internal Server Error', + ok: false, + json: async () => ({ status: 'error', description: 'Internal Server Error' }), + headers: new Headers() + } as unknown as Response) - await expect(messageBoxClient.sendMessage({ - recipient: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4', - messageBox: 'test_inbox', - body: 'Test Message' - })).rejects.toThrow('Message sending failed: HTTP 500 - Internal Server Error') + await expect( + messageBoxClient.sendMessage({ + recipient: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4', + messageBox: 'test_inbox', + body: 'Test Message' + }) + ).rejects.toThrow('Message sending failed: HTTP 500 - Internal Server Error') }) it('throws when every host fails', async () => { - const client = new MessageBoxClient({ walletClient: mockWalletClient, host: 'https://primary', enableLogging: false }) + const client = new MessageBoxClient({ + walletClient: mockWalletClient, + host: 'https://primary', + enableLogging: false + }) await client.init() // Pretend there are no advertised replicas @@ -387,35 +413,41 @@ describe('MessageBoxClient', () => { json: async () => ({ status: 'error', description: 'DB down' }) } as unknown as Response) - await expect(client.listMessages({ messageBox: 'inbox' })) - .rejects.toThrow('Failed to retrieve messages from any host') + await expect(client.listMessages({ messageBox: 'inbox' })).rejects.toThrow( + 'Failed to retrieve messages from any host' + ) }) it('returns [] when at least one host succeeds but has no messages', async () => { - const client = new MessageBoxClient({ walletClient: mockWalletClient, host: 'https://primary', enableLogging: false }) + const client = new MessageBoxClient({ + walletClient: mockWalletClient, + host: 'https://primary', + enableLogging: false + }) await client.init() // One failing replica, one healthy replica - jest.spyOn(client as any, 'queryAdvertisements').mockResolvedValue([{ - host: 'https://replica' - }]) - - jest.spyOn(client.authFetch, 'fetch') - .mockImplementation(async url => - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - url.startsWith('https://primary') - ? await Promise.resolve({ + jest.spyOn(client as any, 'queryAdvertisements').mockResolvedValue([ + { + host: 'https://replica' + } + ]) + + jest.spyOn(client.authFetch, 'fetch').mockImplementation(async url => + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + url.startsWith('https://primary') + ? await Promise.resolve({ ok: false, status: 500, statusText: 'Internal Server Error', json: async () => ({ status: 'error', description: 'DB down' }) } as unknown as Response) - : await Promise.resolve({ + : await Promise.resolve({ ok: true, status: 200, json: async () => ({ status: 'success', messages: [] }) } as unknown as Response) - ) + ) await expect(client.listMessages({ messageBox: 'inbox' })).resolves.toEqual([]) }) @@ -427,16 +459,17 @@ describe('MessageBoxClient', () => { enableLogging: true }) await messageBoxClient.init() - ; (messageBoxClient as any).myIdentityKey = '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' + ;(messageBoxClient as any).myIdentityKey = + '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' - jest.spyOn(messageBoxClient.authFetch, 'fetch') - .mockResolvedValue({ - status: 500, - json: async () => ({ status: 'error', description: 'Failed to acknowledge messages' }) - } as unknown as Response) + jest.spyOn(messageBoxClient.authFetch, 'fetch').mockResolvedValue({ + status: 500, + json: async () => ({ status: 'error', description: 'Failed to acknowledge messages' }) + } as unknown as Response) - await expect(messageBoxClient.acknowledgeMessage({ messageIds: ['42'] })) - .rejects.toThrow('Failed to acknowledge messages') + await expect(messageBoxClient.acknowledgeMessage({ messageIds: ['42'] })).rejects.toThrow( + 'Failed to acknowledge messages' + ) }) it('Emits joinRoom event and listens for incoming messages', async () => { @@ -448,7 +481,9 @@ describe('MessageBoxClient', () => { await messageBoxClient.init() // Mock identity key properly - jest.spyOn(mockWalletClient, 'getPublicKey').mockResolvedValue({ publicKey: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' }) + jest.spyOn(mockWalletClient, 'getPublicKey').mockResolvedValue({ + publicKey: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' + }) // Mock socket with `on` method capturing event handlers const mockSocket = { @@ -458,10 +493,13 @@ describe('MessageBoxClient', () => { // Mock `initializeConnection` so it assigns `socket` & identity key jest.spyOn(messageBoxClient, 'initializeConnection').mockImplementation(async () => { - Object.defineProperty(messageBoxClient, 'testIdentityKey', { get: () => '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' }) - Object.defineProperty(messageBoxClient, 'testSocket', { get: () => mockSocket }); - (messageBoxClient as any).socket = mockSocket; // Ensures internal socket is set - (messageBoxClient as any).myIdentityKey = '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' // Ensures identity key is set + Object.defineProperty(messageBoxClient, 'testIdentityKey', { + get: () => '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' + }) + Object.defineProperty(messageBoxClient, 'testSocket', { get: () => mockSocket }) + ;(messageBoxClient as any).socket = mockSocket // Ensures internal socket is set + ;(messageBoxClient as any).myIdentityKey = + '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' // Ensures identity key is set }) const onMessageMock = jest.fn() @@ -472,14 +510,19 @@ describe('MessageBoxClient', () => { }) // Ensure `joinRoom` event was emitted with the correct identity key - expect(mockSocket.emit).toHaveBeenCalledWith('joinRoom', '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4-test_inbox') + expect(mockSocket.emit).toHaveBeenCalledWith( + 'joinRoom', + '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4-test_inbox' + ) // Simulate receiving a message const receivedMessage = { text: 'Hello, world!' } // Extract & invoke the callback function stored in `on` const sendMessageCallback = mockSocket.on.mock.calls.find( - ([eventName]) => eventName === 'sendMessage-02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4-test_inbox' + ([eventName]) => + eventName === + 'sendMessage-02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4-test_inbox' )?.[1] // Extract the callback function if (typeof sendMessageCallback === 'function') { @@ -499,7 +542,9 @@ describe('MessageBoxClient', () => { await messageBoxClient.init() // Simulate identity key - jest.spyOn(mockWalletClient, 'getPublicKey').mockResolvedValue({ publicKey: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' }) + jest.spyOn(mockWalletClient, 'getPublicKey').mockResolvedValue({ + publicKey: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' + }) // Simulate connection + disconnection + auth success setTimeout(() => { @@ -525,10 +570,13 @@ describe('MessageBoxClient', () => { // Mock `initializeConnection` so it assigns `socket` & identity key jest.spyOn(messageBoxClient, 'initializeConnection').mockImplementation(async () => { - Object.defineProperty(messageBoxClient, 'testIdentityKey', { get: () => '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' }) - Object.defineProperty(messageBoxClient, 'testSocket', { get: () => mockSocket }); - (messageBoxClient as any).socket = mockSocket; // Ensures internal socket is set - (messageBoxClient as any).myIdentityKey = '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' // Ensures identity key is set + Object.defineProperty(messageBoxClient, 'testIdentityKey', { + get: () => '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' + }) + Object.defineProperty(messageBoxClient, 'testSocket', { get: () => mockSocket }) + ;(messageBoxClient as any).socket = mockSocket // Ensures internal socket is set + ;(messageBoxClient as any).myIdentityKey = + '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' // Ensures identity key is set }) // Mock socket to ensure WebSocket validation does not fail @@ -537,11 +585,13 @@ describe('MessageBoxClient', () => { } as any jest.spyOn(messageBoxClient, 'testSocket', 'get').mockReturnValue(mockSocket) - await expect(messageBoxClient.sendLiveMessage({ - recipient: ' ', - messageBox: 'test_inbox', - body: 'Test message' - })).rejects.toThrow('[MB CLIENT ERROR] Recipient identity key is required') + await expect( + messageBoxClient.sendLiveMessage({ + recipient: ' ', + messageBox: 'test_inbox', + body: 'Test message' + }) + ).rejects.toThrow('[MB CLIENT ERROR] Recipient identity key is required') }) it('throws an error when recipient is missing in sendMessage', async () => { @@ -552,23 +602,29 @@ describe('MessageBoxClient', () => { }) await messageBoxClient.init() - await expect(messageBoxClient.sendMessage({ - recipient: '', // Empty recipient - messageBox: 'test_inbox', - body: 'Test message' - })).rejects.toThrow('You must provide a message recipient!') + await expect( + messageBoxClient.sendMessage({ + recipient: '', // Empty recipient + messageBox: 'test_inbox', + body: 'Test message' + }) + ).rejects.toThrow('You must provide a message recipient!') - await expect(messageBoxClient.sendMessage({ - recipient: ' ', // Whitespace recipient - messageBox: 'test_inbox', - body: 'Test message' - })).rejects.toThrow('You must provide a message recipient!') + await expect( + messageBoxClient.sendMessage({ + recipient: ' ', // Whitespace recipient + messageBox: 'test_inbox', + body: 'Test message' + }) + ).rejects.toThrow('You must provide a message recipient!') - await expect(messageBoxClient.sendMessage({ - recipient: null as any, // Null recipient - messageBox: 'test_inbox', - body: 'Test message' - })).rejects.toThrow('You must provide a message recipient!') + await expect( + messageBoxClient.sendMessage({ + recipient: null as any, // Null recipient + messageBox: 'test_inbox', + body: 'Test message' + }) + ).rejects.toThrow('You must provide a message recipient!') }) it('throws an error when messageBox is missing in sendMessage', async () => { @@ -579,23 +635,29 @@ describe('MessageBoxClient', () => { }) await messageBoxClient.init() - await expect(messageBoxClient.sendMessage({ - recipient: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4', - messageBox: '', // Empty messageBox - body: 'Test message' - })).rejects.toThrow('You must provide a messageBox to send this message into!') + await expect( + messageBoxClient.sendMessage({ + recipient: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4', + messageBox: '', // Empty messageBox + body: 'Test message' + }) + ).rejects.toThrow('You must provide a messageBox to send this message into!') - await expect(messageBoxClient.sendMessage({ - recipient: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4', - messageBox: ' ', // Whitespace messageBox - body: 'Test message' - })).rejects.toThrow('You must provide a messageBox to send this message into!') + await expect( + messageBoxClient.sendMessage({ + recipient: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4', + messageBox: ' ', // Whitespace messageBox + body: 'Test message' + }) + ).rejects.toThrow('You must provide a messageBox to send this message into!') - await expect(messageBoxClient.sendMessage({ - recipient: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4', - messageBox: null as any, // Null messageBox - body: 'Test message' - })).rejects.toThrow('You must provide a messageBox to send this message into!') + await expect( + messageBoxClient.sendMessage({ + recipient: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4', + messageBox: null as any, // Null messageBox + body: 'Test message' + }) + ).rejects.toThrow('You must provide a messageBox to send this message into!') }) it('throws an error when message body is missing in sendMessage', async () => { @@ -606,23 +668,29 @@ describe('MessageBoxClient', () => { }) await messageBoxClient.init() - await expect(messageBoxClient.sendMessage({ - recipient: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4', - messageBox: 'test_inbox', - body: '' // Empty body - })).rejects.toThrow('Every message must have a body!') + await expect( + messageBoxClient.sendMessage({ + recipient: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4', + messageBox: 'test_inbox', + body: '' // Empty body + }) + ).rejects.toThrow('Every message must have a body!') - await expect(messageBoxClient.sendMessage({ - recipient: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4', - messageBox: 'test_inbox', - body: ' ' // Whitespace body - })).rejects.toThrow('Every message must have a body!') + await expect( + messageBoxClient.sendMessage({ + recipient: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4', + messageBox: 'test_inbox', + body: ' ' // Whitespace body + }) + ).rejects.toThrow('Every message must have a body!') - await expect(messageBoxClient.sendMessage({ - recipient: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4', - messageBox: 'test_inbox', - body: null as any // Null body - })).rejects.toThrow('Every message must have a body!') + await expect( + messageBoxClient.sendMessage({ + recipient: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4', + messageBox: 'test_inbox', + body: null as any // Null body + }) + ).rejects.toThrow('Every message must have a body!') }) it('throws an error when messageBox is empty in listMessages', async () => { @@ -633,13 +701,17 @@ describe('MessageBoxClient', () => { }) await messageBoxClient.init() - await expect(messageBoxClient.listMessages({ - messageBox: '' // Empty messageBox - })).rejects.toThrow('MessageBox cannot be empty') + await expect( + messageBoxClient.listMessages({ + messageBox: '' // Empty messageBox + }) + ).rejects.toThrow('MessageBox cannot be empty') - await expect(messageBoxClient.listMessages({ - messageBox: ' ' // Whitespace messageBox - })).rejects.toThrow('MessageBox cannot be empty') + await expect( + messageBoxClient.listMessages({ + messageBox: ' ' // Whitespace messageBox + }) + ).rejects.toThrow('MessageBox cannot be empty') }) it('throws an error when messageIds is empty in acknowledgeMessage', async () => { @@ -650,21 +722,29 @@ describe('MessageBoxClient', () => { }) await messageBoxClient.init() - await expect(messageBoxClient.acknowledgeMessage({ - messageIds: [] // Empty array - })).rejects.toThrow('Message IDs array cannot be empty') + await expect( + messageBoxClient.acknowledgeMessage({ + messageIds: [] // Empty array + }) + ).rejects.toThrow('Message IDs array cannot be empty') - await expect(messageBoxClient.acknowledgeMessage({ - messageIds: undefined as any // Undefined value - })).rejects.toThrow('Message IDs array cannot be empty') + await expect( + messageBoxClient.acknowledgeMessage({ + messageIds: undefined as any // Undefined value + }) + ).rejects.toThrow('Message IDs array cannot be empty') - await expect(messageBoxClient.acknowledgeMessage({ - messageIds: null as any // Null value - })).rejects.toThrow('Message IDs array cannot be empty') + await expect( + messageBoxClient.acknowledgeMessage({ + messageIds: null as any // Null value + }) + ).rejects.toThrow('Message IDs array cannot be empty') - await expect(messageBoxClient.acknowledgeMessage({ - messageIds: 'invalid' as any // Not an array - })).rejects.toThrow('Message IDs array cannot be empty') + await expect( + messageBoxClient.acknowledgeMessage({ + messageIds: 'invalid' as any // Not an array + }) + ).rejects.toThrow('Message IDs array cannot be empty') }) it('Uses default host if none is provided', () => { @@ -707,7 +787,6 @@ describe('MessageBoxClient', () => { expect(spy).not.toHaveBeenCalled() }) - it('resolveHostForRecipient returns the first advertised host', async () => { const client = new MessageBoxClient({ walletClient: mockWalletClient, @@ -715,10 +794,11 @@ describe('MessageBoxClient', () => { }) await client.init() - // For this ONE call return two adverts – the first is selected - ; (MessageBoxClient.prototype as any).queryAdvertisements - .mockResolvedValueOnce([ - { host: 'https://peer.box' }, { host: 'https://second.box' }]) + // For this ONE call return two adverts – the first is selected + ;(MessageBoxClient.prototype as any).queryAdvertisements.mockResolvedValueOnce([ + { host: 'https://peer.box' }, + { host: 'https://second.box' } + ]) const result = await client.resolveHostForRecipient('02aa…deadbeef') expect(result).toBe('https://peer.box') @@ -730,8 +810,7 @@ describe('MessageBoxClient', () => { host: 'https://default.box' }) await client.init() - ; (MessageBoxClient.prototype as any).queryAdvertisements - .mockResolvedValueOnce([]) + ;(MessageBoxClient.prototype as any).queryAdvertisements.mockResolvedValueOnce([]) const result = await client.resolveHostForRecipient('03bb…cafef00d') @@ -754,7 +833,9 @@ describe('MessageBoxClient', () => { toASM: () => 'OP_FALSE' } as any) jest.spyOn(Transaction, 'fromAtomicBEEF').mockReturnValue({} as any) - jest.spyOn(TopicBroadcaster.prototype as any, 'broadcast').mockResolvedValue({ txid: 'broadcasted-txid' }) + jest + .spyOn(TopicBroadcaster.prototype as any, 'broadcast') + .mockResolvedValue({ txid: 'broadcasted-txid' }) await client.anointHost('https://fresh.host') @@ -799,7 +880,9 @@ describe('MessageBoxClient', () => { const unlockSignMock = jest .fn() - .mockImplementation(async (_tx: unknown, inputIndex: number) => ({ toHex: () => `unlock-${inputIndex}` })) + .mockImplementation(async (_tx: unknown, inputIndex: number) => ({ + toHex: () => `unlock-${inputIndex}` + })) jest.spyOn(PushDrop.prototype, 'unlock').mockReturnValue({ sign: unlockSignMock } as any) const mergedBeef = { @@ -809,10 +892,10 @@ describe('MessageBoxClient', () => { jest.spyOn(Beef, 'fromBinary').mockImplementation(() => mergedBeef as any) const fromAtomicSpy = jest.spyOn(Transaction, 'fromAtomicBEEF') - fromAtomicSpy - .mockReturnValueOnce({} as any) - .mockReturnValueOnce({} as any) - jest.spyOn(Transaction, 'fromBEEF').mockReturnValue({ outputs: [{ satoshis: 1 }, { satoshis: 1 }] } as any) + fromAtomicSpy.mockReturnValueOnce({} as any).mockReturnValueOnce({} as any) + jest + .spyOn(Transaction, 'fromBEEF') + .mockReturnValue({ outputs: [{ satoshis: 1 }, { satoshis: 1 }] } as any) const createActionSpy = jest.spyOn(mockWalletClient, 'createAction').mockResolvedValue({ signableTransaction: { @@ -824,7 +907,9 @@ describe('MessageBoxClient', () => { tx: [8, 8, 8], txid: 'signed-txid' } as any) - jest.spyOn(TopicBroadcaster.prototype as any, 'broadcast').mockResolvedValue({ txid: 'broadcasted-txid' }) + jest + .spyOn(TopicBroadcaster.prototype as any, 'broadcast') + .mockResolvedValue({ txid: 'broadcasted-txid' }) const result = await client.anointHost('https://new.host') @@ -855,12 +940,15 @@ describe('MessageBoxClient', () => { const recipientA = '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' const recipientB = '03b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' - const makeClient = (): MessageBoxClient => new MessageBoxClient({ - walletClient: mockWalletClient, - host: 'https://default.box' - }) + const makeClient = (): MessageBoxClient => + new MessageBoxClient({ + walletClient: mockWalletClient, + host: 'https://default.box' + }) - const DEFAULT_QUOTE_HEADERS: Record = { 'x-bsv-auth-identity-key': 'delivery-agent' } + const DEFAULT_QUOTE_HEADERS: Record = { + 'x-bsv-auth-identity-key': 'delivery-agent' + } const makeQuoteResponse = ( body: unknown, headers: Record = DEFAULT_QUOTE_HEADERS @@ -878,10 +966,15 @@ describe('MessageBoxClient', () => { }) ) - await expect(client.getMessageBoxQuote({ - recipient: recipientA, - messageBox: 'inbox' - }, 'https://quote.box')).resolves.toEqual({ + await expect( + client.getMessageBoxQuote( + { + recipient: recipientA, + messageBox: 'inbox' + }, + 'https://quote.box' + ) + ).resolves.toEqual({ recipientFee: 12, deliveryFee: 3, deliveryAgentIdentityKey: 'delivery-agent' @@ -896,16 +989,24 @@ describe('MessageBoxClient', () => { it('throws when a single-recipient quote response has no delivery-agent key', async () => { const client = makeClient() jest.spyOn(client.authFetch, 'fetch').mockResolvedValue( - makeQuoteResponse({ - status: 'success', - quote: { recipientFee: 0, deliveryFee: 0 } - }, {}) + makeQuoteResponse( + { + status: 'success', + quote: { recipientFee: 0, deliveryFee: 0 } + }, + {} + ) ) - await expect(client.getMessageBoxQuote({ - recipient: recipientA, - messageBox: 'inbox' - }, 'https://quote.box')).rejects.toThrow('Delivery agent did not provide their identity key') + await expect( + client.getMessageBoxQuote( + { + recipient: recipientA, + messageBox: 'inbox' + }, + 'https://quote.box' + ) + ).rejects.toThrow('Delivery agent did not provide their identity key') }) it('aggregates a multi-recipient quote payload', async () => { @@ -932,10 +1033,15 @@ describe('MessageBoxClient', () => { }) ) - await expect(client.getMessageBoxQuote({ - recipient: [recipientA, recipientB], - messageBox: 'inbox' - }, 'https://quote.box')).resolves.toEqual({ + await expect( + client.getMessageBoxQuote( + { + recipient: [recipientA, recipientB], + messageBox: 'inbox' + }, + 'https://quote.box' + ) + ).resolves.toEqual({ quotesByRecipient: [ { recipient: recipientA, @@ -975,10 +1081,15 @@ describe('MessageBoxClient', () => { }) ) - await expect(client.getMessageBoxQuote({ - recipient: [recipientA, recipientB], - messageBox: 'inbox' - }, 'https://quote.box')).resolves.toMatchObject({ + await expect( + client.getMessageBoxQuote( + { + recipient: [recipientA, recipientB], + messageBox: 'inbox' + }, + 'https://quote.box' + ) + ).resolves.toMatchObject({ quotesByRecipient: [ { recipient: recipientA, @@ -1006,15 +1117,18 @@ describe('MessageBoxClient', () => { it('rejects empty multi-recipient quote requests', async () => { const client = makeClient() - await expect(client.getMessageBoxQuote({ - recipient: [], - messageBox: 'inbox' - })).rejects.toThrow('At least one recipient is required.') + await expect( + client.getMessageBoxQuote({ + recipient: [], + messageBox: 'inbox' + }) + ).rejects.toThrow('At least one recipient is required.') }) it('groups multi-recipient quote requests by resolved host', async () => { const client = makeClient() - jest.spyOn(client, 'resolveHostForRecipient') + jest + .spyOn(client, 'resolveHostForRecipient') .mockResolvedValueOnce('https://one.box') .mockResolvedValueOnce('https://two.box') .mockResolvedValueOnce('https://one.box') @@ -1035,13 +1149,15 @@ describe('MessageBoxClient', () => { const client = makeClient() const accumulator = (client as any).createMultiQuoteAccumulator() - expect(() => (client as any).mergeQuotePayload( - { unexpected: true }, - 'https://quote.box', - [recipientA], - 'inbox', - accumulator - )).toThrow('Unexpected quote response shape from host https://quote.box') + expect(() => + (client as any).mergeQuotePayload( + { unexpected: true }, + 'https://quote.box', + [recipientA], + 'inbox', + accumulator + ) + ).toThrow('Unexpected quote response shape from host https://quote.box') }) }) }) diff --git a/packages/messaging/message-box-client/src/__tests/MessageBoxClientPermissions.test.ts b/packages/messaging/message-box-client/src/__tests/MessageBoxClientPermissions.test.ts new file mode 100644 index 000000000..21d6b8cd9 --- /dev/null +++ b/packages/messaging/message-box-client/src/__tests/MessageBoxClientPermissions.test.ts @@ -0,0 +1,226 @@ +import { jest } from '@jest/globals' +import type { WalletInterface } from '@bsv/sdk' +import { MessageBoxClient } from '../MessageBoxClient.js' + +const wallet = { + getPublicKey: jest.fn().mockResolvedValue({ + publicKey: '02b463b8ef7f03c47fba2679c7334d13e4939b8ca30dbb6bbd22e34ea3e9b1b0e4' + }) +} as unknown as WalletInterface + +function jsonResponse(body: unknown, init: Partial = {}): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: async () => body, + ...init + } as Response +} + +describe('MessageBoxClient permission contract', () => { + let client: MessageBoxClient + let fetchMock: jest.SpiedFunction + + beforeEach(() => { + client = new MessageBoxClient({ + host: 'https://message-box.example/api', + walletClient: wallet + }) + fetchMock = jest.spyOn(client.authFetch, 'fetch') + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('uses the server messageBox query key and maps camelCase records', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + status: 'success', + permissions: [ + { + sender: null, + messageBox: 'notifications', + recipientFee: 0, + createdAt: '2026-07-26T00:00:00.000Z', + updatedAt: '2026-07-26T00:00:00.000Z' + } + ] + }) + ) + + await expect( + client.listMessageBoxPermissions({ messageBox: 'notifications', limit: 20, offset: 5 }) + ).resolves.toEqual([ + { + sender: null, + messageBox: 'notifications', + recipientFee: 0, + status: 'always_allow', + createdAt: '2026-07-26T00:00:00.000Z', + updatedAt: '2026-07-26T00:00:00.000Z' + } + ]) + + expect(fetchMock).toHaveBeenCalledWith( + 'https://message-box.example/api/permissions/list?messageBox=notifications&limit=20&offset=5', + { method: 'GET' } + ) + }) + + it('accepts legacy snake_case records during server rollouts', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + status: 'success', + permissions: [ + { + sender: 'sender-key', + message_box: 'inbox', + recipient_fee: -1, + created_at: '2026-07-26T00:00:00.000Z', + updated_at: '2026-07-26T00:00:00.000Z' + } + ] + }) + ) + + await expect(client.listMessageBoxPermissions()).resolves.toEqual([ + expect.objectContaining({ + sender: 'sender-key', + messageBox: 'inbox', + recipientFee: -1, + status: 'blocked' + }) + ]) + }) + + it('rejects malformed permission responses', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + status: 'success', + permissions: [{ recipientFee: 'free' }] + }) + ) + + await expect(client.listMessageBoxPermissions()).rejects.toThrow( + 'server returned an invalid permission record' + ) + }) + + it('does not send the unused recipient query parameter to the authenticated permission route', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + status: 'success', + permission: null + }) + ) + + await client.getMessageBoxPermission( + { + recipient: '03recipient', + sender: '02sender', + messageBox: 'notifications' + }, + 'https://message-box.example/api' + ) + + expect(fetchMock).toHaveBeenCalledWith( + 'https://message-box.example/api/permissions/get?messageBox=notifications&sender=02sender', + { method: 'GET' } + ) + }) + + it('requires explicit plaintext opt-in for a shared batch', async () => { + await expect( + client.sendMessageToRecipients({ + recipients: ['02recipient'], + messageBox: 'inbox', + body: 'hello' + }) + ).rejects.toThrow('Set skipEncryption: true explicitly') + }) + + it('sends multi-recipient notifications as separately encrypted messages', async () => { + const send = jest + .spyOn(client, 'sendMessage') + .mockResolvedValueOnce({ status: 'success', message: 'ok', messageId: 'one' }) + .mockResolvedValueOnce({ status: 'success', message: 'ok', messageId: 'two' }) + + await expect( + client.sendNotification( + ['02recipient-a', '02recipient-b'], + { title: 'Hello' }, + 'https://message-box.example' + ) + ).resolves.toMatchObject({ + status: 'success', + sent: [ + { recipient: '02recipient-a', messageId: 'one' }, + { recipient: '02recipient-b', messageId: 'two' } + ] + }) + + expect(send).toHaveBeenCalledTimes(2) + expect(send).toHaveBeenNthCalledWith( + 1, + { + recipient: '02recipient-a', + messageBox: 'notifications', + body: { title: 'Hello' }, + checkPermissions: true + }, + 'https://message-box.example' + ) + }) + + it('follows bounded message pages for compatibility with the hardened server', async () => { + const firstPage = Array.from({ length: 1_000 }, (_, index) => ({ + messageId: `message-${index}`, + sender: '02sender', + body: 'plain' + })) + fetchMock + .mockResolvedValueOnce( + jsonResponse({ status: 'success', messages: firstPage, hasMore: true }) + ) + .mockResolvedValueOnce( + jsonResponse({ + status: 'success', + messages: [{ messageId: 'message-1000', sender: '02sender', body: 'plain' }], + hasMore: false + }) + ) + + const messages = await client.listMessagesLite({ + messageBox: 'inbox', + host: 'https://message-box.example/api' + }) + + expect(messages).toHaveLength(1_001) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual({ + messageBox: 'inbox', + limit: 1_000, + offset: 1_000 + }) + }) + + it('does not repeat an exact-size response from a legacy unpaginated server', async () => { + const legacyMessages = Array.from({ length: 1_000 }, (_, index) => ({ + messageId: `legacy-${index}`, + sender: '02sender', + body: 'plain' + })) + fetchMock.mockResolvedValueOnce(jsonResponse({ status: 'success', messages: legacyMessages })) + + await expect( + client.listMessagesLite({ + messageBox: 'inbox', + host: 'https://message-box.example/api' + }) + ).resolves.toHaveLength(1_000) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/messaging/message-box-client/src/__tests/PeerPayClientRequestIntegration.test.ts b/packages/messaging/message-box-client/src/__tests/PeerPayClientRequestIntegration.test.ts index 0f18d069c..d5e881fed 100644 --- a/packages/messaging/message-box-client/src/__tests/PeerPayClientRequestIntegration.test.ts +++ b/packages/messaging/message-box-client/src/__tests/PeerPayClientRequestIntegration.test.ts @@ -8,7 +8,12 @@ * without hitting a real server. */ -import { PeerPayClient, PAYMENT_REQUESTS_MESSAGEBOX, PAYMENT_REQUEST_RESPONSES_MESSAGEBOX, STANDARD_PAYMENT_MESSAGEBOX } from '../PeerPayClient.js' +import { + PeerPayClient, + PAYMENT_REQUESTS_MESSAGEBOX, + PAYMENT_REQUEST_RESPONSES_MESSAGEBOX, + STANDARD_PAYMENT_MESSAGEBOX +} from '../PeerPayClient.js' import { PeerMessage } from '../types.js' import { PrivateKey, WalletInterface } from '@bsv/sdk' import { jest } from '@jest/globals' @@ -43,7 +48,7 @@ const createMockWalletClient = (): MockWallet => ({ encrypt: jest.fn(), decrypt: jest.fn(), revealCounterpartyKeyLinkage: jest.fn(), - revealSpecificKeyLinkage: jest.fn(), + revealSpecificKeyLinkage: jest.fn() }) // --------------------------------------------------------------------------- @@ -58,11 +63,14 @@ class MockMessageBus { // key: `${recipient}::${messageBox}` private readonly store = new Map() - private key (recipient: string, messageBox: string): string { + private key(recipient: string, messageBox: string): string { return `${recipient}::${messageBox}` } - send (params: { recipient: string, messageBox: string, body: string, sender: string }): { status: string, messageId: string } { + send(params: { recipient: string; messageBox: string; body: string; sender: string }): { + status: string + messageId: string + } { const { recipient, messageBox, body, sender } = params const k = this.key(recipient, messageBox) const messageId = `msg-${++this.counter}` @@ -77,11 +85,11 @@ class MockMessageBus { return { status: 'success', messageId } } - list (recipient: string, messageBox: string): PeerMessage[] { + list(recipient: string, messageBox: string): PeerMessage[] { return this.store.get(this.key(recipient, messageBox)) ?? [] } - ack (recipient: string, messageBox: string, messageIds: string[]): void { + ack(recipient: string, messageBox: string, messageIds: string[]): void { const k = this.key(recipient, messageBox) const msgs = this.store.get(k) if (msgs == null) return @@ -90,7 +98,7 @@ class MockMessageBus { } /** Convenience: clear everything */ - reset (): void { + reset(): void { this.store.clear() this.counter = 0 } @@ -109,7 +117,7 @@ class MockMessageBus { * sendPayment is mocked to be a no-op so tests that call fulfillPaymentRequest * don't need a real wallet. */ -function createWiredClient (params: { +function createWiredClient(params: { bus: MockMessageBus identityKey: string walletClient: MockWallet @@ -126,7 +134,8 @@ function createWiredClient (params: { // Wire sendMessage: route to the bus using the current client identity as sender jest.spyOn(client, 'sendMessage').mockImplementation(async (sendParams: any) => { - const body = typeof sendParams.body === 'string' ? sendParams.body : JSON.stringify(sendParams.body) + const body = + typeof sendParams.body === 'string' ? sendParams.body : JSON.stringify(sendParams.body) return bus.send({ recipient: sendParams.recipient, messageBox: sendParams.messageBox, @@ -145,7 +154,11 @@ function createWiredClient (params: { jest.spyOn(client, 'acknowledgeMessage').mockImplementation(async (ackParams: any) => { const { messageIds } = ackParams // Attempt to ack from all known messageBoxes - for (const mb of [PAYMENT_REQUESTS_MESSAGEBOX, PAYMENT_REQUEST_RESPONSES_MESSAGEBOX, STANDARD_PAYMENT_MESSAGEBOX]) { + for (const mb of [ + PAYMENT_REQUESTS_MESSAGEBOX, + PAYMENT_REQUEST_RESPONSES_MESSAGEBOX, + STANDARD_PAYMENT_MESSAGEBOX + ]) { bus.ack(identityKey, mb, messageIds) } return 'acknowledged' @@ -182,7 +195,11 @@ describe('PeerPayClient — Integration: payment request flow', () => { // Test 1: Full round-trip — request → fulfill → requester sees paid response // ------------------------------------------------------------------------- it('Test 1: full round-trip: request → fulfill → requester sees paid response', async () => { - const requester = createWiredClient({ bus, identityKey: REQUESTER_KEY, walletClient: mockWalletRequester }) + const requester = createWiredClient({ + bus, + identityKey: REQUESTER_KEY, + walletClient: mockWalletRequester + }) const payer = createWiredClient({ bus, identityKey: PAYER_KEY, walletClient: mockWalletPayer }) // Requester sends a payment request to payer @@ -214,7 +231,11 @@ describe('PeerPayClient — Integration: payment request flow', () => { // Test 2: Full round-trip — request → decline → requester sees declined // ------------------------------------------------------------------------- it('Test 2: full round-trip: request → decline → requester sees declined response', async () => { - const requester = createWiredClient({ bus, identityKey: REQUESTER_KEY, walletClient: mockWalletRequester }) + const requester = createWiredClient({ + bus, + identityKey: REQUESTER_KEY, + walletClient: mockWalletRequester + }) const payer = createWiredClient({ bus, identityKey: PAYER_KEY, walletClient: mockWalletPayer }) const { requestId } = await requester.requestPayment({ @@ -238,7 +259,11 @@ describe('PeerPayClient — Integration: payment request flow', () => { // Test 3: Request → cancel → payer no longer sees the request // ------------------------------------------------------------------------- it('Test 3: request → cancel → payer no longer sees the request', async () => { - const requester = createWiredClient({ bus, identityKey: REQUESTER_KEY, walletClient: mockWalletRequester }) + const requester = createWiredClient({ + bus, + identityKey: REQUESTER_KEY, + walletClient: mockWalletRequester + }) const payer = createWiredClient({ bus, identityKey: PAYER_KEY, walletClient: mockWalletPayer }) const { requestId, requestProof } = await requester.requestPayment({ @@ -275,7 +300,12 @@ describe('PeerPayClient — Integration: payment request flow', () => { senderIdentityKey: REQUESTER_KEY, requestProof: 'mock-proof' }) - bus.send({ recipient: PAYER_KEY, messageBox: PAYMENT_REQUESTS_MESSAGEBOX, body: expiredBody, sender: REQUESTER_KEY }) + bus.send({ + recipient: PAYER_KEY, + messageBox: PAYMENT_REQUESTS_MESSAGEBOX, + body: expiredBody, + sender: REQUESTER_KEY + }) // Also inject a valid request so the filter has something to keep const validBody = JSON.stringify({ @@ -286,7 +316,12 @@ describe('PeerPayClient — Integration: payment request flow', () => { senderIdentityKey: REQUESTER_KEY, requestProof: 'mock-proof' }) - bus.send({ recipient: PAYER_KEY, messageBox: PAYMENT_REQUESTS_MESSAGEBOX, body: validBody, sender: REQUESTER_KEY }) + bus.send({ + recipient: PAYER_KEY, + messageBox: PAYMENT_REQUESTS_MESSAGEBOX, + body: validBody, + sender: REQUESTER_KEY + }) const requests = await payer.listIncomingPaymentRequests() expect(requests).toHaveLength(1) @@ -308,7 +343,12 @@ describe('PeerPayClient — Integration: payment request flow', () => { senderIdentityKey: REQUESTER_KEY, requestProof: 'mock-proof' }) - bus.send({ recipient: PAYER_KEY, messageBox: PAYMENT_REQUESTS_MESSAGEBOX, body: smallBody, sender: REQUESTER_KEY }) + bus.send({ + recipient: PAYER_KEY, + messageBox: PAYMENT_REQUESTS_MESSAGEBOX, + body: smallBody, + sender: REQUESTER_KEY + }) // Inject a valid request that passes the filter const okBody = JSON.stringify({ @@ -319,9 +359,17 @@ describe('PeerPayClient — Integration: payment request flow', () => { senderIdentityKey: REQUESTER_KEY, requestProof: 'mock-proof' }) - bus.send({ recipient: PAYER_KEY, messageBox: PAYMENT_REQUESTS_MESSAGEBOX, body: okBody, sender: REQUESTER_KEY }) + bus.send({ + recipient: PAYER_KEY, + messageBox: PAYMENT_REQUESTS_MESSAGEBOX, + body: okBody, + sender: REQUESTER_KEY + }) - const requests = await payer.listIncomingPaymentRequests(undefined, { minAmount: 1000, maxAmount: 10000 }) + const requests = await payer.listIncomingPaymentRequests(undefined, { + minAmount: 1000, + maxAmount: 10000 + }) expect(requests).toHaveLength(1) expect(requests[0].requestId).toBe('req-ok') @@ -349,7 +397,12 @@ describe('PeerPayClient — Integration: payment request flow', () => { senderIdentityKey: REQUESTER_KEY, requestProof: 'mock-proof' }) - bus.send({ recipient: PAYER_KEY, messageBox: PAYMENT_REQUESTS_MESSAGEBOX, body: largeBody, sender: REQUESTER_KEY }) + bus.send({ + recipient: PAYER_KEY, + messageBox: PAYMENT_REQUESTS_MESSAGEBOX, + body: largeBody, + sender: REQUESTER_KEY + }) // Inject a valid request that passes the filter const okBody = JSON.stringify({ @@ -360,9 +413,17 @@ describe('PeerPayClient — Integration: payment request flow', () => { senderIdentityKey: REQUESTER_KEY, requestProof: 'mock-proof' }) - bus.send({ recipient: PAYER_KEY, messageBox: PAYMENT_REQUESTS_MESSAGEBOX, body: okBody, sender: REQUESTER_KEY }) + bus.send({ + recipient: PAYER_KEY, + messageBox: PAYMENT_REQUESTS_MESSAGEBOX, + body: okBody, + sender: REQUESTER_KEY + }) - const requests = await payer.listIncomingPaymentRequests(undefined, { minAmount: 1000, maxAmount: 10000 }) + const requests = await payer.listIncomingPaymentRequests(undefined, { + minAmount: 1000, + maxAmount: 10000 + }) expect(requests).toHaveLength(1) expect(requests[0].requestId).toBe('req-ok-2') diff --git a/packages/messaging/message-box-client/src/__tests/PeerPayClientUnit.test.ts b/packages/messaging/message-box-client/src/__tests/PeerPayClientUnit.test.ts index ce3b226e4..d7fd9f9e5 100644 --- a/packages/messaging/message-box-client/src/__tests/PeerPayClientUnit.test.ts +++ b/packages/messaging/message-box-client/src/__tests/PeerPayClientUnit.test.ts @@ -23,15 +23,18 @@ const toArray = (msg: any, enc?: 'hex' | 'utf8' | 'base64'): any[] => { } } -const createMockWalletClient = (): jest.Mocked => ({ - getPublicKey: jest.fn(), - createAction: jest.fn(), - internalizeAction: jest.fn(), - createHmac: jest.fn<() => Promise>().mockResolvedValue({ - hmac: [1, 2, 3, 4, 5] - }), - verifyHmac: jest.fn<() => Promise<{ valid: true }>>().mockResolvedValue({ valid: true as const }) -} as unknown as jest.Mocked) +const createMockWalletClient = (): jest.Mocked => + ({ + getPublicKey: jest.fn(), + createAction: jest.fn(), + internalizeAction: jest.fn(), + createHmac: jest.fn<() => Promise>().mockResolvedValue({ + hmac: [1, 2, 3, 4, 5] + }), + verifyHmac: jest + .fn<() => Promise<{ valid: true }>>() + .mockResolvedValue({ valid: true as const }) + }) as unknown as jest.Mocked describe('PeerPayClient Unit Tests', () => { let peerPayClient: PeerPayClient @@ -75,21 +78,24 @@ describe('PeerPayClient Unit Tests', () => { it('should throw an error if recipient public key cannot be derived', async () => { mockWalletClient.getPublicKey.mockResolvedValue({ publicKey: '' }) // Empty key - await expect(peerPayClient.createPaymentToken({ recipient: 'invalid', amount: 5 })) - .rejects.toThrow('Failed to derive recipient’s public key') + await expect( + peerPayClient.createPaymentToken({ recipient: 'invalid', amount: 5 }) + ).rejects.toThrow('Failed to derive recipient’s public key') }) it('should throw an error if amount is <= 0', async () => { - (mockWalletClient.getPublicKey as jest.MockedFunction) - .mockResolvedValue({ - publicKey: PrivateKey.fromRandom().toPublicKey().toString() - }) + ;( + mockWalletClient.getPublicKey as jest.MockedFunction + ).mockResolvedValue({ + publicKey: PrivateKey.fromRandom().toPublicKey().toString() + }) - await expect(peerPayClient.createPaymentToken({ - recipient: PrivateKey.fromRandom().toPublicKey().toString(), - amount: 0 - })) - .rejects.toThrow('Invalid payment details: recipient and valid amount are required') + await expect( + peerPayClient.createPaymentToken({ + recipient: PrivateKey.fromRandom().toPublicKey().toString(), + amount: 0 + }) + ).rejects.toThrow('Invalid payment details: recipient and valid amount are required') }) }) @@ -107,11 +113,14 @@ describe('PeerPayClient Unit Tests', () => { await peerPayClient.sendPayment(payment) console.log('[TEST] sendPayment finished.') - expect(sendMessageSpy).toHaveBeenCalledWith({ - recipient: 'recipientKey', - messageBox: 'payment_inbox', - body: expect.any(String) - }, undefined) + expect(sendMessageSpy).toHaveBeenCalledWith( + { + recipient: 'recipientKey', + messageBox: 'payment_inbox', + body: expect.any(String) + }, + undefined + ) }, 10000) }) @@ -136,11 +145,14 @@ describe('PeerPayClient Unit Tests', () => { await peerPayClient.sendLivePayment(payment) expect(peerPayClient.createPaymentToken).toHaveBeenCalledWith(payment) - expect(peerPayClient.sendLiveMessage).toHaveBeenCalledWith({ - recipient: 'recipientKey', - messageBox: 'payment_inbox', - body: '{"customInstructions":{"derivationPrefix":"prefix","derivationSuffix":"suffix"},"transaction":[1,2,3,4,5],"amount":2}' - }, undefined) + expect(peerPayClient.sendLiveMessage).toHaveBeenCalledWith( + { + recipient: 'recipientKey', + messageBox: 'payment_inbox', + body: '{"customInstructions":{"derivationPrefix":"prefix","derivationSuffix":"suffix"},"transaction":[1,2,3,4,5],"amount":2}' + }, + undefined + ) }) }) @@ -360,7 +372,9 @@ describe('PeerPayClient Unit Tests', () => { expect(requests).toHaveLength(1) expect(requests[0].requestId).toBe('req-other') - expect(ackSpy).toHaveBeenCalledWith({ messageIds: expect.arrayContaining(['original-msg', 'cancel-msg']) }) + expect(ackSpy).toHaveBeenCalledWith({ + messageIds: expect.arrayContaining(['original-msg', 'cancel-msg']) + }) }) it('discards malformed messages (invalid JSON) and acknowledges them', async () => { @@ -393,9 +407,11 @@ describe('PeerPayClient Unit Tests', () => { expect(requests).toHaveLength(1) expect(requests[0].requestId).toBe('req-good') - expect(ackSpy).toHaveBeenCalledWith(expect.objectContaining({ - messageIds: expect.arrayContaining(['bad-msg']) - })) + expect(ackSpy).toHaveBeenCalledWith( + expect.objectContaining({ + messageIds: expect.arrayContaining(['bad-msg']) + }) + ) }) it('discards messages with missing required fields and acknowledges them', async () => { @@ -413,9 +429,11 @@ describe('PeerPayClient Unit Tests', () => { const requests = await peerPayClient.listIncomingPaymentRequests() expect(requests).toHaveLength(0) - expect(ackSpy).toHaveBeenCalledWith(expect.objectContaining({ - messageIds: expect.arrayContaining(['incomplete-msg']) - })) + expect(ackSpy).toHaveBeenCalledWith( + expect.objectContaining({ + messageIds: expect.arrayContaining(['incomplete-msg']) + }) + ) }) it('only cancels requests from the same sender', async () => { @@ -504,11 +522,16 @@ describe('PeerPayClient Unit Tests', () => { ]) const ackSpy = jest.spyOn(peerPayClient, 'acknowledgeMessage').mockResolvedValue('ok') - const requests = await peerPayClient.listIncomingPaymentRequests(undefined, { minAmount: 1000, maxAmount: 10000 }) + const requests = await peerPayClient.listIncomingPaymentRequests(undefined, { + minAmount: 1000, + maxAmount: 10000 + }) expect(requests).toHaveLength(1) expect(requests[0].requestId).toBe('req-ok') - expect(ackSpy).toHaveBeenCalledWith({ messageIds: expect.arrayContaining(['too-small', 'too-large']) }) + expect(ackSpy).toHaveBeenCalledWith({ + messageIds: expect.arrayContaining(['too-small', 'too-large']) + }) }) }) @@ -539,7 +562,11 @@ describe('PeerPayClient Unit Tests', () => { ) const responseBody = JSON.parse((sendMessageSpy.mock.calls[0][0] as any).body) - expect(responseBody).toMatchObject({ requestId: 'req-id-1', status: 'paid', amountPaid: 5000 }) + expect(responseBody).toMatchObject({ + requestId: 'req-id-1', + status: 'paid', + amountPaid: 5000 + }) expect(ackSpy).toHaveBeenCalledWith({ messageIds: ['req-msg-1'] }) }) @@ -588,7 +615,11 @@ describe('PeerPayClient Unit Tests', () => { ) const responseBody = JSON.parse((sendMessageSpy.mock.calls[0][0] as any).body) - expect(responseBody).toMatchObject({ requestId: 'req-id-2', status: 'declined', note: 'Not today' }) + expect(responseBody).toMatchObject({ + requestId: 'req-id-2', + status: 'declined', + note: 'Not today' + }) expect(ackSpy).toHaveBeenCalledWith({ messageIds: ['req-msg-2'] }) }) @@ -618,14 +649,20 @@ describe('PeerPayClient Unit Tests', () => { expect(responses).toHaveLength(2) expect(responses[0]).toMatchObject({ requestId: 'req-1', status: 'paid', amountPaid: 5000 }) - expect(responses[1]).toMatchObject({ requestId: 'req-2', status: 'declined', note: 'No funds' }) + expect(responses[1]).toMatchObject({ + requestId: 'req-2', + status: 'declined', + note: 'No funds' + }) }) }) // Test: listenForLivePaymentRequests describe('listenForLivePaymentRequests', () => { it('calls listenForLiveMessages on payment_requests box and converts messages to IncomingPaymentRequest', async () => { - const listenSpy = jest.spyOn(peerPayClient, 'listenForLiveMessages').mockResolvedValue(undefined) + const listenSpy = jest + .spyOn(peerPayClient, 'listenForLiveMessages') + .mockResolvedValue(undefined) const onRequest = jest.fn() await peerPayClient.listenForLivePaymentRequests({ onRequest }) @@ -635,7 +672,7 @@ describe('PeerPayClient Unit Tests', () => { ) // Simulate a message arriving by calling the onMessage callback - const { onMessage } = (listenSpy.mock.calls[0][0] as any) + const { onMessage } = listenSpy.mock.calls[0][0] as any onMessage({ messageId: 'live-msg-1', sender: 'sender1', @@ -660,7 +697,9 @@ describe('PeerPayClient Unit Tests', () => { // Test: listenForLivePaymentRequestResponses describe('listenForLivePaymentRequestResponses', () => { it('calls listenForLiveMessages on payment_request_responses box and parses responses', async () => { - const listenSpy = jest.spyOn(peerPayClient, 'listenForLiveMessages').mockResolvedValue(undefined) + const listenSpy = jest + .spyOn(peerPayClient, 'listenForLiveMessages') + .mockResolvedValue(undefined) const onResponse = jest.fn() await peerPayClient.listenForLivePaymentRequestResponses({ onResponse }) @@ -670,7 +709,7 @@ describe('PeerPayClient Unit Tests', () => { ) // Simulate a message arriving - const { onMessage } = (listenSpy.mock.calls[0][0] as any) + const { onMessage } = listenSpy.mock.calls[0][0] as any onMessage({ messageId: 'live-resp-1', sender: 'payer1', @@ -688,7 +727,9 @@ describe('PeerPayClient Unit Tests', () => { // Test: allowPaymentRequestsFrom describe('allowPaymentRequestsFrom', () => { it('calls setMessageBoxPermission with messageBox=payment_requests and recipientFee=0', async () => { - const setPermSpy = jest.spyOn(peerPayClient, 'setMessageBoxPermission').mockResolvedValue(undefined) + const setPermSpy = jest + .spyOn(peerPayClient, 'setMessageBoxPermission') + .mockResolvedValue(undefined) await peerPayClient.allowPaymentRequestsFrom({ identityKey: 'trustedKey' }) @@ -703,7 +744,9 @@ describe('PeerPayClient Unit Tests', () => { // Test: blockPaymentRequestsFrom describe('blockPaymentRequestsFrom', () => { it('calls setMessageBoxPermission with recipientFee=-1', async () => { - const setPermSpy = jest.spyOn(peerPayClient, 'setMessageBoxPermission').mockResolvedValue(undefined) + const setPermSpy = jest + .spyOn(peerPayClient, 'setMessageBoxPermission') + .mockResolvedValue(undefined) await peerPayClient.blockPaymentRequestsFrom({ identityKey: 'blockedKey' }) @@ -785,12 +828,14 @@ describe('PeerPayClient Unit Tests', () => { }) it('throws if amount <= 0', async () => { - await expect(peerPayClient.requestPayment({ - recipient: 'recipientKey', - amount: 0, - description: 'Bad request', - expiresAt: Date.now() + 60000 - })).rejects.toThrow() + await expect( + peerPayClient.requestPayment({ + recipient: 'recipientKey', + amount: 0, + description: 'Bad request', + expiresAt: Date.now() + 60000 + }) + ).rejects.toThrow() }) }) diff --git a/packages/messaging/message-box-client/src/__tests/PeerTokenClientUnit.test.ts b/packages/messaging/message-box-client/src/__tests/PeerTokenClientUnit.test.ts index f930d0ebf..058e203c2 100644 --- a/packages/messaging/message-box-client/src/__tests/PeerTokenClientUnit.test.ts +++ b/packages/messaging/message-box-client/src/__tests/PeerTokenClientUnit.test.ts @@ -4,13 +4,16 @@ import { TokenSettlementAdapter } from '../TokenSettlementAdapter.js' import { PrivateKey, type WalletInterface } from '@bsv/sdk' import { jest } from '@jest/globals' -const createMockWalletClient = (): jest.Mocked => ({ - getPublicKey: jest.fn(), - createAction: jest.fn(), - internalizeAction: jest.fn(), - createHmac: jest.fn<() => Promise<{ hmac: number[] }>>().mockResolvedValue({ hmac: [1, 2, 3] }), - verifyHmac: jest.fn<() => Promise<{ valid: true }>>().mockResolvedValue({ valid: true as const }) -} as unknown as jest.Mocked) +const createMockWalletClient = (): jest.Mocked => + ({ + getPublicKey: jest.fn(), + createAction: jest.fn(), + internalizeAction: jest.fn(), + createHmac: jest.fn<() => Promise<{ hmac: number[] }>>().mockResolvedValue({ hmac: [1, 2, 3] }), + verifyHmac: jest + .fn<() => Promise<{ valid: true }>>() + .mockResolvedValue({ valid: true as const }) + }) as unknown as jest.Mocked const ARTIFACT = { customInstructions: { derivationPrefix: 'cHJl', derivationSuffix: 'c3Vm' }, @@ -23,9 +26,11 @@ const ARTIFACT = { const makeStubAdapter = (): TokenSettlementAdapter => ({ protocol: 'stas', - buildTokenSettlement: jest.fn() + buildTokenSettlement: jest + .fn() .mockResolvedValue({ action: 'settle', artifact: ARTIFACT }), - acceptTokenSettlement: jest.fn() + acceptTokenSettlement: jest + .fn() .mockResolvedValue({ action: 'accept', receiptData: { internalizeResult: 'ok' } }) }) @@ -53,8 +58,18 @@ describe('PeerTokenClient Unit Tests', () => { describe('createTokenToken', () => { it('delegates to the adapter and returns the artifact fields', async () => { - const token = await client.createTokenToken({ recipient, protocol: 'stas', source: SOURCE, amount: '1000' }) - expect(token).toMatchObject({ protocol: 'stas', assetId: 'TEST', amount: '1000', outputIndex: 0 }) + const token = await client.createTokenToken({ + recipient, + protocol: 'stas', + source: SOURCE, + amount: '1000' + }) + expect(token).toMatchObject({ + protocol: 'stas', + assetId: 'TEST', + amount: '1000', + outputIndex: 0 + }) expect(token.transaction).toEqual([1, 2, 3]) expect(adapter.buildTokenSettlement).toHaveBeenCalledTimes(1) }) @@ -67,7 +82,8 @@ describe('PeerTokenClient Unit Tests', () => { it('throws when the adapter terminates', async () => { ;(adapter.buildTokenSettlement as jest.Mock).mockResolvedValue({ - action: 'terminate', termination: { code: 'stas.frozen', message: 'frozen UTXO' } + action: 'terminate', + termination: { code: 'stas.frozen', message: 'frozen UTXO' } } as never) await expect( client.createTokenToken({ recipient, protocol: 'stas', source: SOURCE, amount: '1000' }) @@ -76,15 +92,26 @@ describe('PeerTokenClient Unit Tests', () => { it('surfaces the broadcast txid from the artifact', async () => { ;(adapter.buildTokenSettlement as jest.Mock).mockResolvedValue({ - action: 'settle', artifact: { ...ARTIFACT, txid: 'cd'.repeat(32) } + action: 'settle', + artifact: { ...ARTIFACT, txid: 'cd'.repeat(32) } } as never) - const token = await client.createTokenToken({ recipient, protocol: 'stas', source: SOURCE, amount: '1000' }) + const token = await client.createTokenToken({ + recipient, + protocol: 'stas', + source: SOURCE, + amount: '1000' + }) expect(token.txid).toBe('cd'.repeat(32)) }) it('passes dryRun through to the adapter context', async () => { - await client.createTokenToken({ recipient, protocol: 'stas', source: SOURCE, amount: '1000' }, true) - const ctx = (adapter.buildTokenSettlement as jest.Mock).mock.calls[0][1] as { dryRun?: boolean } + await client.createTokenToken( + { recipient, protocol: 'stas', source: SOURCE, amount: '1000' }, + true + ) + const ctx = (adapter.buildTokenSettlement as jest.Mock).mock.calls[0][1] as { + dryRun?: boolean + } expect(ctx.dryRun).toBe(true) }) }) @@ -97,12 +124,21 @@ describe('PeerTokenClient Unit Tests', () => { const arg = (sendSpy.mock.calls[0] as any)[0] expect(arg.messageBox).toBe(STANDARD_TOKEN_MESSAGEBOX) expect(arg.recipient).toBe(recipient) - expect(JSON.parse(arg.body)).toMatchObject({ protocol: 'stas', assetId: 'TEST', amount: '1000' }) + expect(JSON.parse(arg.body)).toMatchObject({ + protocol: 'stas', + assetId: 'TEST', + amount: '1000' + }) }) it('returns the sent token', async () => { jest.spyOn(client, 'sendMessage' as any).mockResolvedValue(undefined as never) - const token = await client.sendToken({ recipient, protocol: 'stas', source: SOURCE, amount: '1000' }) + const token = await client.sendToken({ + recipient, + protocol: 'stas', + source: SOURCE, + amount: '1000' + }) expect(token).toMatchObject({ protocol: 'stas', assetId: 'TEST', amount: '1000' }) }) @@ -115,7 +151,9 @@ describe('PeerTokenClient Unit Tests', () => { describe('acceptToken', () => { it('delegates to the adapter and acknowledges the message', async () => { - const ackSpy = jest.spyOn(client, 'acknowledgeMessage' as any).mockResolvedValue(undefined as never) + const ackSpy = jest + .spyOn(client, 'acknowledgeMessage' as any) + .mockResolvedValue(undefined as never) const incoming = { messageId: 'msg-1', sender: PrivateKey.fromRandom().toPublicKey().toString(), @@ -132,10 +170,32 @@ describe('PeerTokenClient Unit Tests', () => { it('reads via listMessagesLite (mainnet bypass) with the configured host', async () => { const liteSpy = jest.spyOn(client, 'listMessagesLite' as any).mockResolvedValue([] as never) await client.listIncomingTokens() - expect(liteSpy).toHaveBeenCalledWith(expect.objectContaining({ - messageBox: STANDARD_TOKEN_MESSAGEBOX, - host: 'https://message-box-us-1.bsvb.tech' - })) + expect(liteSpy).toHaveBeenCalledWith( + expect.objectContaining({ + messageBox: STANDARD_TOKEN_MESSAGEBOX, + host: 'https://message-box-us-1.bsvb.tech' + }) + ) }) }) + + describe('request proof validation', () => { + it.each(['', '0', 'zz', '123x'])( + 'rejects malformed hexadecimal proofs without calling the wallet (%s)', + async requestProof => { + const wallet = createMockWalletClient() + wallet.getPublicKey.mockResolvedValue({ publicKey: recipient }) + const proofClient = new PeerTokenClient({ walletClient: wallet, adapters: [adapter] }) + + await expect( + proofClient.verifyTokenRequestProof({ + requestId: 'request-1', + sender: recipient, + requestProof + }) + ).resolves.toBe(false) + expect(wallet.verifyHmac).not.toHaveBeenCalled() + } + ) + }) }) diff --git a/packages/messaging/message-box-client/src/__tests/RemittanceAdapter.test.ts b/packages/messaging/message-box-client/src/__tests/RemittanceAdapter.test.ts index d39342858..721493c12 100644 --- a/packages/messaging/message-box-client/src/__tests/RemittanceAdapter.test.ts +++ b/packages/messaging/message-box-client/src/__tests/RemittanceAdapter.test.ts @@ -9,48 +9,64 @@ describe('RemittanceAdapter', () => { it('delegates sendMessage and returns the transport messageId', async () => { const messageBox = { - sendMessage: jest.fn<() => Promise<{ status: string, messageId: string }>>().mockResolvedValue({ - status: 'success', - messageId: 'http-mid' - }) + sendMessage: jest + .fn<() => Promise<{ status: string; messageId: string }>>() + .mockResolvedValue({ + status: 'success', + messageId: 'http-mid' + }) } as unknown as MessageBoxClient const adapter = new RemittanceAdapter(messageBox) - const result = await adapter.sendMessage({ - recipient: senderKey, - messageBox: 'remittance_inbox', - body: '{"v":1}' - }, 'https://override-host') + const result = await adapter.sendMessage( + { + recipient: senderKey, + messageBox: 'remittance_inbox', + body: '{"v":1}' + }, + 'https://override-host' + ) expect(result).toBe('http-mid') - expect(messageBox.sendMessage).toHaveBeenCalledWith({ - recipient: senderKey, - messageBox: 'remittance_inbox', - body: '{"v":1}' - }, 'https://override-host') + expect(messageBox.sendMessage).toHaveBeenCalledWith( + { + recipient: senderKey, + messageBox: 'remittance_inbox', + body: '{"v":1}' + }, + 'https://override-host' + ) }) it('delegates sendLiveMessage (live path) and returns the transport messageId', async () => { const messageBox = { - sendLiveMessage: jest.fn<() => Promise<{ status: string, messageId: string }>>().mockResolvedValue({ - status: 'success', - messageId: 'ws-mid' - }) + sendLiveMessage: jest + .fn<() => Promise<{ status: string; messageId: string }>>() + .mockResolvedValue({ + status: 'success', + messageId: 'ws-mid' + }) } as unknown as MessageBoxClient const adapter = new RemittanceAdapter(messageBox) - const result = await adapter.sendLiveMessage({ - recipient: senderKey, - messageBox: 'remittance_inbox', - body: '{"v":1}' - }, 'https://override-host') + const result = await adapter.sendLiveMessage( + { + recipient: senderKey, + messageBox: 'remittance_inbox', + body: '{"v":1}' + }, + 'https://override-host' + ) expect(result).toBe('ws-mid') - expect(messageBox.sendLiveMessage).toHaveBeenCalledWith({ - recipient: senderKey, - messageBox: 'remittance_inbox', - body: '{"v":1}' - }, 'https://override-host') + expect(messageBox.sendLiveMessage).toHaveBeenCalledWith( + { + recipient: senderKey, + messageBox: 'remittance_inbox', + body: '{"v":1}' + }, + 'https://override-host' + ) }) it('normalizes listMessages shape for remittance and forwards host', async () => { @@ -106,13 +122,15 @@ describe('RemittanceAdapter', () => { it('forwards live listener setup and normalizes inbound message shape', async () => { const onPaymentMessage = jest.fn() - let forwardedListener: ((msg: { - messageId: string - sender: string - body: unknown - created_at: string - updated_at: string - }) => void) | undefined + let forwardedListener: + | ((msg: { + messageId: string + sender: string + body: unknown + created_at: string + updated_at: string + }) => void) + | undefined const messageBox = { getIdentityKey: jest.fn<() => Promise>().mockResolvedValue(myIdentityKey), diff --git a/packages/messaging/message-box-client/src/__tests/host.test.ts b/packages/messaging/message-box-client/src/__tests/host.test.ts new file mode 100644 index 000000000..3dc865900 --- /dev/null +++ b/packages/messaging/message-box-client/src/__tests/host.test.ts @@ -0,0 +1,52 @@ +import { + messageBoxEndpoint, + normalizeMessageBoxHost, + normalizeOverlayMessageBoxHost +} from '../host.js' + +describe('Message Box host validation', () => { + it.each([ + ['https://message.example.org/', 'https://message.example.org'], + ['http://localhost:8080/api/', 'http://localhost:8080/api'], + [' https://message.example.org/prefix/// ', 'https://message.example.org/prefix'] + ])('normalizes explicitly configured host %s', (input, expected) => { + expect(normalizeMessageBoxHost(input)).toBe(expected) + }) + + it.each([ + 'message.example.org', + 'file:///tmp/socket', + 'https://user:secret@message.example.org', + 'https://message.example.org?redirect=https://attacker.example', + 'https://message.example.org/#fragment', + '' + ])('rejects unsafe or ambiguous configured host %s', input => { + expect(() => normalizeMessageBoxHost(input)).toThrow() + }) + + it('preserves an operator-controlled route prefix when building endpoints', () => { + expect(messageBoxEndpoint('https://message.example.org/api/', '/sendMessage')).toBe( + 'https://message.example.org/api/sendMessage' + ) + }) + + it.each([ + 'http://message.example.org', + 'https://localhost:8080', + 'https://127.0.0.1', + 'https://10.0.0.4', + 'https://172.16.0.1', + 'https://192.168.1.1', + 'https://[::1]', + 'https://service.internal', + 'https://message.example.com' + ])('rejects unsafe overlay destination %s', input => { + expect(normalizeOverlayMessageBoxHost(input)).toBeUndefined() + }) + + it('accepts and canonicalizes a public HTTPS overlay destination', () => { + expect(normalizeOverlayMessageBoxHost('https://message.example.org/api/')).toBe( + 'https://message.example.org/api' + ) + }) +}) diff --git a/packages/messaging/message-box-client/src/__tests/integration/integrationEncrypted.test.ts b/packages/messaging/message-box-client/src/__tests/integration/integrationEncrypted.test.ts index e63ec66da..5db7aea18 100644 --- a/packages/messaging/message-box-client/src/__tests/integration/integrationEncrypted.test.ts +++ b/packages/messaging/message-box-client/src/__tests/integration/integrationEncrypted.test.ts @@ -4,13 +4,14 @@ import { WalletClient } from '@bsv/sdk' import { webcrypto } from 'crypto' import { expect, test, describe, beforeAll } from '@jest/globals' -(global as any).self = { crypto: webcrypto } +;(global as any).self = { crypto: webcrypto } jest.setTimeout(20000) -const walletClient = new WalletClient('json-api', 'https://message-box-us-1.bsvb.tech') +const integrationHost = process.env.MESSAGE_BOX_INTEGRATION_HOST! +const walletClient = new WalletClient('json-api', process.env.MESSAGE_BOX_WALLET_ORIGINATOR!) const messageBoxClient = new MessageBoxClient({ - host: 'https://message-box-us-1.bsvb.tech', + host: integrationHost, walletClient, enableLogging: true, networkPreset: 'local' @@ -83,14 +84,11 @@ describe('Encryption Integration Tests', () => { expect(sendResult.status).toBe('success') // Manually fetch raw HTTP response - const fetch = await messageBoxClient.authFetch.fetch( - 'https://message-box-us-1.bsvb.tech/listMessages', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ messageBox }) - } - ) + const fetch = await messageBoxClient.authFetch.fetch(`${integrationHost}/listMessages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messageBox }) + }) const raw = await fetch.json() const rawBody = raw.messages.at(-1)?.body diff --git a/packages/messaging/message-box-client/src/__tests/integration/integrationHTTP.test.ts b/packages/messaging/message-box-client/src/__tests/integration/integrationHTTP.test.ts index 192ff9245..dca64ee11 100644 --- a/packages/messaging/message-box-client/src/__tests/integration/integrationHTTP.test.ts +++ b/packages/messaging/message-box-client/src/__tests/integration/integrationHTTP.test.ts @@ -4,18 +4,19 @@ import { WalletClient } from '@bsv/sdk' import { webcrypto } from 'crypto' // Ensure Jest doesn't mock WalletClient -jest.unmock('@bsv/sdk'); +jest.unmock('@bsv/sdk') -(global as any).self = { crypto: webcrypto } +;(global as any).self = { crypto: webcrypto } jest.setTimeout(20000) // Explicitly initialize WalletClient with Meta Net Client (MNC) -const walletClient = new WalletClient('json-api', 'localhost') +const integrationHost = process.env.MESSAGE_BOX_INTEGRATION_HOST! +const walletClient = new WalletClient('json-api', process.env.MESSAGE_BOX_WALLET_ORIGINATOR!) // Initialize MessageBoxClient for HTTP-Only Testing const messageBoxClient = new MessageBoxClient({ - host: 'https://message-box-us-1.bsvb.tech', + host: integrationHost, walletClient }) diff --git a/packages/messaging/message-box-client/src/__tests/integration/integrationOverlay.test.ts b/packages/messaging/message-box-client/src/__tests/integration/integrationOverlay.test.ts index 53c600778..fc9aff565 100644 --- a/packages/messaging/message-box-client/src/__tests/integration/integrationOverlay.test.ts +++ b/packages/messaging/message-box-client/src/__tests/integration/integrationOverlay.test.ts @@ -3,14 +3,15 @@ import { MessageBoxClient } from '../../MessageBoxClient.js' import { WalletClient } from '@bsv/sdk' import { webcrypto } from 'crypto' -(global as any).self = { crypto: webcrypto } +;(global as any).self = { crypto: webcrypto } jest.setTimeout(20000) -const MESSAGEBOX_HOST = 'http://localhost:5001' +const MESSAGEBOX_HOST = process.env.MESSAGE_BOX_INTEGRATION_HOST! +const walletOriginator = process.env.MESSAGE_BOX_WALLET_ORIGINATOR! -const walletA = new WalletClient('json-api', 'localhost') -const walletB = new WalletClient('json-api', 'localhost') +const walletA = new WalletClient('json-api', walletOriginator) +const walletB = new WalletClient('json-api', walletOriginator) const clientA = new MessageBoxClient({ host: MESSAGEBOX_HOST, diff --git a/packages/messaging/message-box-client/src/__tests/integration/integrationWS.test.ts b/packages/messaging/message-box-client/src/__tests/integration/integrationWS.test.ts index 3f07473a0..ea9669afb 100644 --- a/packages/messaging/message-box-client/src/__tests/integration/integrationWS.test.ts +++ b/packages/messaging/message-box-client/src/__tests/integration/integrationWS.test.ts @@ -4,17 +4,17 @@ import { PeerMessage } from '../../types.js' import { WalletClient } from '@bsv/sdk' import { webcrypto } from 'crypto' -(global as any).self = { crypto: webcrypto } +;(global as any).self = { crypto: webcrypto } jest.setTimeout(20000) -const WS_URL = 'https://message-box-us-1.bsvb.tech' +const WS_URL = process.env.MESSAGE_BOX_INTEGRATION_HOST! let recipientKey: string const messageBox = 'testBox' const testMessage = 'Hello, this is a WebSocket integration test.' -const walletClient = new WalletClient('json-api', 'localhost') +const walletClient = new WalletClient('json-api', process.env.MESSAGE_BOX_WALLET_ORIGINATOR!) const messageBoxClient = new MessageBoxClient({ host: WS_URL, walletClient diff --git a/packages/messaging/message-box-client/src/__tests/integration/testServer.ts b/packages/messaging/message-box-client/src/__tests/integration/testServer.ts deleted file mode 100644 index bdf44eaf6..000000000 --- a/packages/messaging/message-box-client/src/__tests/integration/testServer.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { spawn } from 'node:child_process' -import { resolve } from 'node:path' - -let serverProcess: any = null - -async function isServerRunning (): Promise { - try { - await fetch('http://localhost:8080/health') // Use an actual health check route - return true - } catch { - return false - } -} - -/** - * Starts the MessageBoxServer as a separate process if not already running. - */ -export async function startTestServer (): Promise { - if (await isServerRunning()) { - console.log('Test server already running.') - return - } - - console.log('Starting test server...') - serverProcess = spawn('npm', ['run', 'dev'], { - cwd: resolve(__dirname, '../../../MessageBoxServer'), - stdio: 'inherit', - shell: true - }) - - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Test server startup timed out')) - }, 10000) - - serverProcess.on('error', (err) => { - console.error('Test server failed to start:', err) - clearTimeout(timeout) - reject(err) - }) - - setTimeout(() => { - console.log('Test server started.') - clearTimeout(timeout) - resolve(undefined) - }, 3000) - }) -} - -/** - * Stops the MessageBoxServer process after tests. - */ -export async function stopTestServer (): Promise { - if (serverProcess === null) { - console.warn('Test server process is already stopped or undefined.') - return - } - - console.log('Stopping test server...') - try { - serverProcess.kill() - console.log('Test server stopped.') - } catch (error) { - console.error('Error stopping test server:', error) - } - - serverProcess = null -} diff --git a/packages/messaging/message-box-client/src/host.ts b/packages/messaging/message-box-client/src/host.ts new file mode 100644 index 000000000..506a71cd4 --- /dev/null +++ b/packages/messaging/message-box-client/src/host.ts @@ -0,0 +1,110 @@ +const MAX_HOST_LENGTH = 2048 + +function isPrivateIpv4(hostname: string): boolean { + const octets = hostname.split('.').map(part => Number(part)) + if ( + octets.length !== 4 || + octets.some(octet => !Number.isInteger(octet) || octet < 0 || octet > 255) + ) { + return false + } + + const [first, second] = octets + return ( + first === 0 || + first === 10 || + first === 127 || + (first === 100 && second >= 64 && second <= 127) || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) || + (first === 198 && (second === 18 || second === 19)) + ) +} + +function isPrivateIpv6(hostname: string): boolean { + const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase() + return ( + normalized === '::' || + normalized === '::1' || + normalized.startsWith('fc') || + normalized.startsWith('fd') || + /^fe[89ab]/.test(normalized) + ) +} + +function isLocalHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/\.$/, '') + return ( + normalized === 'localhost' || + normalized.endsWith('.localhost') || + normalized.endsWith('.local') || + normalized.endsWith('.lan') || + normalized.endsWith('.home') || + normalized.endsWith('.internal') || + normalized.endsWith('.test') || + normalized.endsWith('.invalid') || + normalized === 'example.com' || + normalized.endsWith('.example.com') || + isPrivateIpv4(normalized) || + isPrivateIpv6(normalized) + ) +} + +/** + * Validates and canonicalizes an explicitly configured Message Box base URL. + * + * HTTP remains supported for operator-controlled local development. Overlay + * advertisements use the stricter `normalizeOverlayMessageBoxHost` boundary. + */ +export function normalizeMessageBoxHost(host: string): string { + if (typeof host !== 'string') throw new TypeError('Message Box host must be a string') + + const candidate = host.trim() + if (candidate === '' || candidate.length > MAX_HOST_LENGTH) { + throw new TypeError('Message Box host must be a non-empty URL of at most 2048 characters') + } + + let url: URL + try { + url = new URL(candidate) + } catch { + throw new TypeError('Message Box host must be an absolute HTTP(S) URL') + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new TypeError('Message Box host must use HTTP or HTTPS') + } + if (url.username !== '' || url.password !== '') { + throw new TypeError('Message Box host must not contain credentials') + } + if (url.search !== '' || url.hash !== '') { + throw new TypeError('Message Box host must not contain a query or fragment') + } + + const pathname = url.pathname.replace(/\/+$/, '') + return pathname === '' ? url.origin : `${url.origin}${pathname}` +} + +/** + * Validates an untrusted overlay advertisement before it can become a network + * destination. Advertisements must use HTTPS and must not target local, + * reserved, or documentation-only hostnames. + */ +export function normalizeOverlayMessageBoxHost(host: string): string | undefined { + try { + const normalized = normalizeMessageBoxHost(host) + const url = new URL(normalized) + if (url.protocol !== 'https:' || isLocalHostname(url.hostname)) return undefined + return normalized + } catch { + return undefined + } +} + +export function messageBoxEndpoint(host: string, path: string): string { + const normalizedHost = normalizeMessageBoxHost(host) + const normalizedPath = path.replace(/^\/+/, '') + if (normalizedPath === '') throw new TypeError('Message Box endpoint path must not be empty') + return `${normalizedHost}/${normalizedPath}` +} diff --git a/packages/messaging/message-box-client/src/types.ts b/packages/messaging/message-box-client/src/types.ts index 361897b1b..607629a6f 100644 --- a/packages/messaging/message-box-client/src/types.ts +++ b/packages/messaging/message-box-client/src/types.ts @@ -1,4 +1,18 @@ -import { AtomicBEEF, Base64String, BasketStringUnder300Bytes, BEEF, BooleanDefaultTrue, DescriptionString5to50Bytes, HexString, LabelStringUnder300Bytes, LockingScript, OutputTagStringUnder300Bytes, PositiveIntegerOrZero, PubKeyHex, WalletInterface } from '@bsv/sdk' +import { + AtomicBEEF, + Base64String, + BasketStringUnder300Bytes, + BEEF, + BooleanDefaultTrue, + DescriptionString5to50Bytes, + HexString, + LabelStringUnder300Bytes, + LockingScript, + OutputTagStringUnder300Bytes, + PositiveIntegerOrZero, + PubKeyHex, + WalletInterface +} from '@bsv/sdk' /** * Configuration options for initializing a MessageBoxClient. @@ -278,7 +292,7 @@ export interface TokenToken { assetId: string /** Token units as a string (bigint-safe). */ amount: string - customInstructions: { derivationPrefix: Base64String, derivationSuffix: Base64String } + customInstructions: { derivationPrefix: Base64String; derivationSuffix: Base64String } transaction: AtomicBEEF outputIndex?: number /** Broadcast txid of the transfer, surfaced by the adapter (explorer links). */ diff --git a/packages/messaging/message-box-client/src/types/permissions.ts b/packages/messaging/message-box-client/src/types/permissions.ts index 8f8d8a8e5..7d1c5ec79 100644 --- a/packages/messaging/message-box-client/src/types/permissions.ts +++ b/packages/messaging/message-box-client/src/types/permissions.ts @@ -89,9 +89,9 @@ export interface SendListParams { export interface SendListResult { status: 'success' | 'partial' | 'error' description: string - sent: Array<{ recipient: PubKeyHex, messageId: string }> + sent: Array<{ recipient: PubKeyHex; messageId: string }> blocked: PubKeyHex[] - failed: Array<{ recipient: PubKeyHex, error: string }> + failed: Array<{ recipient: PubKeyHex; error: string }> totals?: { deliveryFees: number recipientFees: number diff --git a/packages/messaging/message-box-client/ts2md.json b/packages/messaging/message-box-client/ts2md.json deleted file mode 100644 index 1814ef338..000000000 --- a/packages/messaging/message-box-client/ts2md.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "inputFilename": "./src/MessageBoxClient.ts", - "firstHeadingLevel": 3, - "noTitle": true, - "readmeMerge": true - } - \ No newline at end of file diff --git a/packages/messaging/message-box-client/tsconfig.base.json b/packages/messaging/message-box-client/tsconfig.base.json index fa1156804..a04fe1c64 100644 --- a/packages/messaging/message-box-client/tsconfig.base.json +++ b/packages/messaging/message-box-client/tsconfig.base.json @@ -1,37 +1,26 @@ { "compilerOptions": { - "lib": [ - "dom", - "ESNext" - ], + "lib": ["dom", "ESNext"], "module": "NodeNext", - "target": "esnext", + "target": "ES2022", "moduleResolution": "NodeNext", "moduleDetection": "force", "rootDir": "./", - "strict": false, + "strict": true, "isolatedModules": true, - "strictNullChecks": false, + "strictNullChecks": true, + "useUnknownInCatchVariables": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "incremental": true, + "incremental": false, "sourceMap": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "resolveJsonModule": true, "esModuleInterop": true, - "types": ["jest", "node"] + "types": ["node"] }, - "files": [ - "mod.ts" - ], - "include": [ - "src" - ], - "exclude": [ - "dist", - "node_modules", - "src/__tests" - ] - -} \ No newline at end of file + "files": ["mod.ts"], + "include": ["src"], + "exclude": ["dist", "node_modules", "src/__tests"] +} diff --git a/packages/messaging/message-box-client/tsconfig.cjs.json b/packages/messaging/message-box-client/tsconfig.cjs.json deleted file mode 100644 index aef79fe9b..000000000 --- a/packages/messaging/message-box-client/tsconfig.cjs.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "target": "es2019", - "module": "commonjs", - "moduleResolution": "bundler", - "rootDir": "./", - "outDir": "./dist/cjs" - } -} \ No newline at end of file diff --git a/packages/messaging/message-box-client/tsconfig.esm.json b/packages/messaging/message-box-client/tsconfig.esm.json deleted file mode 100644 index efa568e3e..000000000 --- a/packages/messaging/message-box-client/tsconfig.esm.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "rootDir": "./", - "outDir": "./dist/esm", - "allowSyntheticDefaultImports": true - } -} diff --git a/packages/messaging/message-box-client/tsconfig.json b/packages/messaging/message-box-client/tsconfig.json index 0a2227998..ddf30e27a 100644 --- a/packages/messaging/message-box-client/tsconfig.json +++ b/packages/messaging/message-box-client/tsconfig.json @@ -1,19 +1,3 @@ { - "files": [], - "compilerOptions": { - "types": ["jest", "node"], - "esModuleInterop": true, - "strictNullChecks": true - }, - "references": [ - { - "path": "tsconfig.esm.json" - }, - { - "path": "tsconfig.cjs.json" - }, - { - "path": "tsconfig.types.json" - } - ] + "extends": "./tsconfig.typecheck.json" } diff --git a/packages/messaging/message-box-client/tsconfig.typecheck.json b/packages/messaging/message-box-client/tsconfig.typecheck.json new file mode 100644 index 000000000..54a20013b --- /dev/null +++ b/packages/messaging/message-box-client/tsconfig.typecheck.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "incremental": false, + "noEmit": true + }, + "include": ["mod.ts", "src/**/*.ts"], + "exclude": ["dist", "node_modules", "src/__tests"] +} diff --git a/packages/messaging/message-box-client/tsconfig.types.json b/packages/messaging/message-box-client/tsconfig.types.json deleted file mode 100644 index 18a4da5c8..000000000 --- a/packages/messaging/message-box-client/tsconfig.types.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "rootDir": "./", - "outDir": "./dist/types", - "emitDeclarationOnly": true, - "declaration": true, - "declarationMap": true, - "typeRoots": ["./src/utils/types", "./node_modules/@types"] - } -} diff --git a/packages/messaging/message-box-client/tsdown.config.ts b/packages/messaging/message-box-client/tsdown.config.ts new file mode 100644 index 000000000..50e8b4b5b --- /dev/null +++ b/packages/messaging/message-box-client/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + clean: true, + dts: true, + entry: ['mod.ts'], + format: ['cjs', 'esm'], + outDir: 'dist', + platform: 'browser', + sourcemap: true, + tsconfig: 'tsconfig.base.json', + unbundle: true +}) diff --git a/packages/messaging/message-box-client/webpack.config.js b/packages/messaging/message-box-client/webpack.config.js index 43e249697..36863cff0 100644 --- a/packages/messaging/message-box-client/webpack.config.js +++ b/packages/messaging/message-box-client/webpack.config.js @@ -6,7 +6,7 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) export default { - entry: './dist/esm/mod.js', + entry: './dist/mod.js', output: { filename: 'bundle.js', // Output single bundled file path: path.resolve(__dirname, 'dist', 'umd'), // Output directory @@ -18,8 +18,13 @@ export default { extensions: ['.ts', '.js'] // Resolve both TypeScript and JavaScript files }, mode: 'production', + devtool: 'source-map', module: { rules: [ + { + test: /[/\\]transaction[/\\]http[/\\](?:DefaultHttpClient|BinaryFetchClient)\.js$/, + use: path.resolve(__dirname, 'scripts', 'remove-node-https-fallback.cjs') + }, { test: /\.ts$/, // Use ts-loader to transpile TypeScript files use: 'ts-loader', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b19db3ee..bf0e23ab4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -302,7 +302,7 @@ importers: version: 5.109.0(esbuild@0.28.1)(webpack-cli@7.2.1) webpack-cli: specifier: ^7.2.1 - version: 7.2.1(json5@2.2.3)(toml@3.0.0)(webpack-bundle-analyzer@5.3.1)(webpack@5.109.0) + version: 7.2.1(json5@2.2.3)(toml@3.0.0)(webpack@5.109.0) packages/helpers/fund-wallet: dependencies: @@ -474,7 +474,7 @@ importers: version: 5.109.0(esbuild@0.28.1)(webpack-cli@7.2.1) webpack-cli: specifier: ^7.2.1 - version: 7.2.1(json5@2.2.3)(toml@3.0.0)(webpack-bundle-analyzer@5.3.1)(webpack@5.109.0) + version: 7.2.1(json5@2.2.3)(toml@3.0.0)(webpack@5.109.0) packages/messaging/message-box-client: dependencies: @@ -482,12 +482,6 @@ importers: specifier: workspace:^ version: link:../authsocket-client devDependencies: - '@bsv/auth-express-middleware': - specifier: workspace:^ - version: link:../../middleware/auth-express-middleware - '@bsv/payment-express-middleware': - specifier: workspace:^ - version: link:../../middleware/payment-express-middleware '@bsv/sdk': specifier: workspace:^ version: link:../../sdk @@ -497,48 +491,30 @@ importers: '@types/node': specifier: ^26.1.1 version: 26.1.1 - '@types/supertest': - specifier: ^7.2.1 - version: 7.2.1 - ejs: - specifier: ^6.0.1 - version: 6.0.1 jest: specifier: ^30.4.2 version: 30.4.2(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@6.0.3)) - jest-fetch-mock: - specifier: ^4.2.0 - version: 4.2.0 oxlint: specifier: ^1.75.0 version: 1.75.0 + rimraf: + specifier: ^6.1.3 + version: 6.1.3 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)(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.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@6.0.3)))(typescript@6.0.3) - ts-node: - specifier: ^10.9.2 - version: 10.9.2(@types/node@26.1.1)(typescript@6.0.3) - ts2md: - specifier: ^0.2.8 - version: 0.2.8 - tsconfig-to-dual-package: - specifier: ^1.2.0 - version: 1.2.0(typescript@6.0.3) + tsdown: + specifier: 0.22.14 + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.22)(tsx@4.23.1)(typescript@6.0.3) typescript: specifier: ^6.0.3 version: 6.0.3 webpack: specifier: ^5.109.0 version: 5.109.0(esbuild@0.28.1)(webpack-cli@7.2.1) - webpack-bundle-analyzer: - specifier: ^5.3.1 - version: 5.3.1 webpack-cli: specifier: ^7.2.1 - version: 7.2.1(json5@2.2.3)(toml@3.0.0)(webpack-bundle-analyzer@5.3.1)(webpack@5.109.0) - webpack-merge: - specifier: ^6.0.1 - version: 6.0.1 + version: 7.2.1(json5@2.2.3)(toml@3.0.0)(webpack@5.109.0) packages/messaging/ts-paymail: dependencies: @@ -2001,10 +1977,6 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} - '@discoveryjs/json-ext@0.6.3': - resolution: {integrity: sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==} - engines: {node: '>=14.17.0'} - '@discoveryjs/json-ext@1.1.0': resolution: {integrity: sha512-Xc3VhU02wqZ1HvHRJUwL09HkZSTvidqY5Ya0NXBSYOxAp+Ln9dcJr9fySI+CkONzP3PekQo9WdzCv0PGER/mOA==} engines: {node: '>=14.17.0'} @@ -2666,9 +2638,6 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} - '@polka/url@1.0.0-next.29': - resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@publint/pack@0.1.6': resolution: {integrity: sha512-3uVNyGcVplhPZSLVyeIpL7+cIRn1YCSNHLG/rUIlBQMVH8YuN9++YF+5+UDIIO9RW98dujiUoTltO7RDB5bFJA==} engines: {node: '>=18'} @@ -4334,9 +4303,6 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - cross-fetch@3.2.0: - resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} - cross-fetch@4.1.0: resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} @@ -4662,11 +4628,6 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - ejs@6.0.1: - resolution: {integrity: sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==} - engines: {node: '>=0.12.18'} - hasBin: true - electron-to-chromium@1.5.396: resolution: {integrity: sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==} @@ -5065,6 +5026,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -5156,9 +5121,6 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - html-escaper@3.0.3: - resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} - html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -5492,10 +5454,6 @@ packages: resolution: {integrity: sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-fetch-mock@4.2.0: - resolution: {integrity: sha512-/Yya+k/bopdCSG+cEIL9Kxj1cr4j73DeOQiroicwrTkTEfyMsnQxrugjSopn8RnwginmX/Nlsp57BGy1I1mQSg==} - engines: {node: '>=18'} - jest-get-type@29.6.3: resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -6140,6 +6098,10 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -6257,10 +6219,6 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} - mrmime@2.0.1: - resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} - engines: {node: '>=10'} - ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -6410,10 +6368,6 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} - opener@1.5.2: - resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} - hasBin: true - oxlint@1.75.0: resolution: {integrity: sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6517,6 +6471,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -6812,6 +6770,11 @@ packages: engines: {node: '>= 0.4'} hasBin: true + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -6962,10 +6925,6 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - sirv@3.0.2: - resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} - engines: {node: '>=18'} - sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -7225,10 +7184,6 @@ packages: toml@3.0.0: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} - totalist@3.0.1: - resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} - engines: {node: '>=6'} - tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -7639,11 +7594,6 @@ packages: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} - webpack-bundle-analyzer@5.3.1: - resolution: {integrity: sha512-wP2EusncRGL1tZyMHC/umLkjPdYMkTL9nPEKh8G8dkYCJ9TyF6xnXFqjhdqmv5J900irN/g0P5jMvLT22krEXQ==} - engines: {node: '>= 20.9.0'} - hasBin: true - webpack-cli@7.2.1: resolution: {integrity: sha512-YwSGbcZdfz12DM8JIseVPr3oBb09IgVCVc4vY3oDvZnI/mALTGPAP1QiqOi4/bBLSJrRHaqDIXeHcNA0+G38aw==} engines: {node: '>=20.9.0'} @@ -8448,8 +8398,6 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} - '@discoveryjs/json-ext@0.6.3': {} - '@discoveryjs/json-ext@1.1.0': {} '@dnsquery/dns-packet@6.1.1': @@ -9369,8 +9317,6 @@ snapshots: '@pkgr/core@0.3.6': {} - '@polka/url@1.0.0-next.29': {} - '@publint/pack@0.1.6': dependencies: tinyexec: 1.2.4 @@ -10855,12 +10801,6 @@ snapshots: create-require@1.1.1: {} - cross-fetch@3.2.0: - dependencies: - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding - cross-fetch@4.1.0: dependencies: node-fetch: 2.7.0 @@ -11179,8 +11119,6 @@ snapshots: ee-first@1.1.1: {} - ejs@6.0.1: {} - electron-to-chromium@1.5.396: {} emittery@0.13.1: {} @@ -11643,6 +11581,12 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -11783,8 +11727,6 @@ snapshots: html-escaper@2.0.2: {} - html-escaper@3.0.3: {} - html-void-elements@3.0.0: {} http-errors@2.0.1: @@ -12189,12 +12131,6 @@ snapshots: jest-util: 30.4.1 jest-validate: 30.4.1 - jest-fetch-mock@4.2.0: - dependencies: - cross-fetch: 3.2.0 - transitivePeerDependencies: - - encoding - jest-get-type@29.6.3: {} jest-haste-map@30.4.1: @@ -13354,6 +13290,10 @@ snapshots: min-indent@1.0.1: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.8 + minimatch@3.1.5: dependencies: brace-expansion: 5.0.8 @@ -13459,8 +13399,6 @@ snapshots: mri@1.2.0: {} - mrmime@2.0.1: {} - ms@2.0.0: {} ms@2.1.2: {} @@ -13588,8 +13526,6 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 - opener@1.5.2: {} - oxlint@1.75.0: optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.75.0 @@ -13702,6 +13638,11 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + path-to-regexp@8.4.2: {} pathe@2.0.3: {} @@ -14076,6 +14017,11 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + robust-predicates@3.0.3: {} rolldown-plugin-dts@0.27.14(rolldown@1.2.0)(typescript@6.0.3): @@ -14310,12 +14256,6 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 - sirv@3.0.2: - dependencies: - '@polka/url': 1.0.0-next.29 - mrmime: 2.0.1 - totalist: 3.0.1 - sisteransi@1.0.5: {} slash@3.0.0: {} @@ -14620,8 +14560,6 @@ snapshots: toml@3.0.0: {} - totalist@3.0.1: {} - tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -15005,23 +14943,7 @@ snapshots: webidl-conversions@7.0.0: {} - webpack-bundle-analyzer@5.3.1: - dependencies: - '@discoveryjs/json-ext': 0.6.3 - acorn: 8.17.0 - acorn-walk: 8.3.5 - commander: 14.0.3 - escape-string-regexp: 5.0.0 - html-escaper: 3.0.3 - opener: 1.5.2 - picocolors: 1.1.1 - sirv: 3.0.2 - ws: 8.21.1 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - webpack-cli@7.2.1(json5@2.2.3)(toml@3.0.0)(webpack-bundle-analyzer@5.3.1)(webpack@5.109.0): + webpack-cli@7.2.1(json5@2.2.3)(toml@3.0.0)(webpack@5.109.0): dependencies: '@discoveryjs/json-ext': 1.1.0 commander: 14.0.3 @@ -15035,7 +14957,6 @@ snapshots: optionalDependencies: json5: 2.2.3 toml: 3.0.0 - webpack-bundle-analyzer: 5.3.1 webpack-merge@6.0.1: dependencies: @@ -15104,7 +15025,7 @@ snapshots: watchpack: 2.5.2 webpack-sources: 3.5.1 optionalDependencies: - webpack-cli: 7.2.1(json5@2.2.3)(toml@3.0.0)(webpack-bundle-analyzer@5.3.1)(webpack@5.109.0) + webpack-cli: 7.2.1(json5@2.2.3)(toml@3.0.0)(webpack@5.109.0) transitivePeerDependencies: - '@minify-html/node' - '@swc/core' diff --git a/specs/messaging/message-box-http.yaml b/specs/messaging/message-box-http.yaml index 40f723529..85cf882d1 100644 --- a/specs/messaging/message-box-http.yaml +++ b/specs/messaging/message-box-http.yaml @@ -1,31 +1,28 @@ -openapi: "3.1.0" +openapi: '3.1.0' info: title: MessageBox Server HTTP API - version: "1.0.0" + version: '1.0.0' description: | - REST API for the BSV MessageBox Server. All endpoints require BRC-31 - mutual authentication unless noted otherwise. Authentication is carried - via the `x-bsv-auth-*` header family produced by the auth-express-middleware. + REST API for the BSV MessageBox Server. All endpoints require BRC-103 + mutual authentication over the BRC-104 HTTP binding unless noted + otherwise. Authentication is carried via the `x-bsv-auth-*` header family + produced by auth-express-middleware. - A lightweight Bearer token scheme is also accepted for backwards-compatible - clients (the swagger-ui exposes BearerAuth). In practice, mutual-auth via - `x-bsv-auth-*` headers is the standard approach. - - Source of truth: `packages/messaging/message-box-server/src/routes/` + Source of truth: `infra/message-box-server/src/routes/` servers: - - url: "https://{host}" - description: MessageBox server (production default messagebox.babbage.systems) + - url: 'https://{host}' + description: MessageBox server variables: host: - default: messagebox.babbage.systems + default: message-box-us-1.bsvb.tech description: Hostname of the running MessageBox Server instance - - url: "http://localhost:{port}" + - url: 'http://localhost:{port}' description: Local development server variables: port: - default: "5001" + default: '5001' components: securitySchemes: @@ -39,24 +36,16 @@ components: your-nonce, request-id, signature). The server validates the signature and sets `req.auth.identityKey` for downstream route handlers. See `specs/auth/brc103-mutual-auth.yaml` for the full protocol description. - BearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - description: | - Legacy Bearer token for swagger-ui exploration. Not used in production; - prefer BsvMutualAuth. - schemas: # ------------------------------------------------------------------------- # Shared primitives # ------------------------------------------------------------------------- PubKeyHex: type: string - pattern: "^0[23][0-9a-fA-F]{64}$" + pattern: '^0[23][0-9a-fA-F]{64}$' description: Compressed secp256k1 public key encoded as a 66-character hex string. examples: - - "028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1" + - '028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1' AtomicBEEF: type: array @@ -99,10 +88,10 @@ components: properties: recipient: oneOf: - - $ref: "#/components/schemas/PubKeyHex" + - $ref: '#/components/schemas/PubKeyHex' - type: array items: - $ref: "#/components/schemas/PubKeyHex" + $ref: '#/components/schemas/PubKeyHex' description: | Identity key of the recipient, or an array of recipient identity keys. Alias: `recipients` (array form only). Both fields are accepted for @@ -110,31 +99,36 @@ components: recipients: type: array items: - $ref: "#/components/schemas/PubKeyHex" + $ref: '#/components/schemas/PubKeyHex' description: Preferred plural form. Takes precedence over `recipient` when both are present. messageBox: type: string + minLength: 1 + maxLength: 128 description: Named message box (e.g. `payment_inbox`, `notifications`). examples: - payment_inbox messageId: oneOf: - type: string + minLength: 1 + maxLength: 256 - type: array items: type: string + minLength: 1 + maxLength: 256 description: | Unique identifier(s) for the message(s) — typically an HMAC. When `recipients` has more than one entry, `messageId` must be an array of the same length with one ID per recipient. examples: - - "xyz123" + - 'xyz123' body: - oneOf: - - type: string - - type: object - additionalProperties: true - description: Message payload. Strings and JSON objects are both accepted. + type: string + description: | + Serialized message payload. The official client serializes object + bodies before sending them. PaymentOutput: type: object @@ -158,7 +152,7 @@ components: type: string description: Base64-encoded derivation suffix. senderIdentityKey: - $ref: "#/components/schemas/PubKeyHex" + $ref: '#/components/schemas/PubKeyHex' customInstructions: description: | Arbitrary per-output routing instructions. The server attempts to @@ -189,11 +183,11 @@ components: has set a `recipientFee > 0` or the server has a `deliveryFee > 0`. properties: tx: - $ref: "#/components/schemas/AtomicBEEF" + $ref: '#/components/schemas/AtomicBEEF' outputs: type: array items: - $ref: "#/components/schemas/PaymentOutput" + $ref: '#/components/schemas/PaymentOutput' description: | Output list. When a server delivery fee applies, output[0] must be the server's delivery fee output. Subsequent outputs are recipient-side. @@ -220,7 +214,7 @@ components: type: string description: Stringified message body (JSON objects are JSON.stringify-ed before storage). sender: - $ref: "#/components/schemas/PubKeyHex" + $ref: '#/components/schemas/PubKeyHex' createdAt: type: string format: date-time @@ -244,8 +238,8 @@ components: properties: sender: oneOf: - - $ref: "#/components/schemas/PubKeyHex" - - type: "null" + - $ref: '#/components/schemas/PubKeyHex' + - type: 'null' description: Sender identity key, or null for a box-wide default. messageBox: type: string @@ -253,7 +247,7 @@ components: type: integer description: -1 = blocked, 0 = always allow, positive = satoshi amount required. status: - $ref: "#/components/schemas/PermissionStatus" + $ref: '#/components/schemas/PermissionStatus' createdAt: type: string format: date-time @@ -297,6 +291,55 @@ security: - BsvMutualAuth: [] paths: + /health: + get: + operationId: health + summary: Report process liveness + tags: [Meta] + security: [] + responses: + '200': + description: Process is alive. + headers: + Cache-Control: + schema: + type: string + description: Always `no-store`. + content: + application/json: + schema: + type: object + required: [status] + properties: + status: + type: string + enum: [ok] + + /ready: + get: + operationId: readiness + summary: Report database readiness + tags: [Meta] + security: [] + responses: + '200': + description: Required dependencies are ready. + content: + application/json: + schema: + type: object + required: [status] + properties: + status: + type: string + enum: [ready] + '503': + description: A required dependency is unavailable. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + # --------------------------------------------------------------------------- # Message operations # --------------------------------------------------------------------------- @@ -336,20 +379,20 @@ paths: required: [message] properties: message: - $ref: "#/components/schemas/MessageObject" + $ref: '#/components/schemas/MessageObject' payment: - $ref: "#/components/schemas/Payment" + $ref: '#/components/schemas/Payment' examples: single_recipient_free: summary: Send to one recipient (no payment required) value: message: - recipient: "028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1" + recipient: '028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1' messageBox: payment_inbox messageId: xyz123 - body: "{}" + body: '{}' responses: - "200": + '200': description: Message(s) sent successfully. content: application/json: @@ -370,10 +413,10 @@ paths: required: [recipient, messageId] properties: recipient: - $ref: "#/components/schemas/PubKeyHex" + $ref: '#/components/schemas/PubKeyHex' messageId: type: string - "400": + '400': description: | Bad request. Possible codes: - `ERR_MESSAGE_REQUIRED` @@ -393,14 +436,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - "401": + $ref: '#/components/schemas/ErrorResponse' + '401': description: Authentication required (`ERR_AUTH_REQUIRED`). content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - "403": + $ref: '#/components/schemas/ErrorResponse' + '403': description: | One or more recipients have blocked the sender (`ERR_DELIVERY_BLOCKED`). The `blockedRecipients` field lists the affected identity keys. @@ -408,28 +451,29 @@ paths: application/json: schema: allOf: - - $ref: "#/components/schemas/ErrorResponse" + - $ref: '#/components/schemas/ErrorResponse' - type: object properties: blockedRecipients: type: array items: - $ref: "#/components/schemas/PubKeyHex" - "500": + $ref: '#/components/schemas/PubKeyHex' + '500': description: Internal server error (`ERR_INTERNAL`, `ERR_INTERNALIZE_FAILED`). content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' /listMessages: post: operationId: listMessages summary: Retrieve messages from a named message box description: | - Returns all stored messages belonging to the authenticated identity in the - specified message box. Returns an empty array if the box does not exist. - Message bodies are always returned as strings (JSON objects are stringified). + Returns one deterministic, bounded page of stored messages belonging + to the authenticated identity in the specified message box. Returns an + empty array if the box does not exist. Message bodies are always + returned as strings (JSON objects are stringified). tags: [Message] requestBody: required: true @@ -441,45 +485,64 @@ paths: properties: messageBox: type: string + minLength: 1 + maxLength: 128 description: Name of the message box to retrieve messages from. examples: - payment_inbox + limit: + type: integer + minimum: 1 + maximum: 1000 + default: 1000 + offset: + type: integer + minimum: 0 + maximum: 100000 + default: 0 responses: - "200": + '200': description: Messages retrieved successfully (array may be empty). content: application/json: schema: type: object - required: [status, messages] + required: [status, messages, limit, offset, hasMore] properties: status: type: string enum: [success] messages: type: array + maxItems: 1000 items: - $ref: "#/components/schemas/StoredMessage" - "400": + $ref: '#/components/schemas/StoredMessage' + limit: + type: integer + offset: + type: integer + hasMore: + type: boolean + '400': description: | Invalid or missing message box name. Codes: `ERR_MESSAGEBOX_REQUIRED`, `ERR_INVALID_MESSAGEBOX` content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - "401": + $ref: '#/components/schemas/ErrorResponse' + '401': description: Authentication required. content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - "500": + $ref: '#/components/schemas/ErrorResponse' + '500': description: Internal server error (`ERR_INTERNAL_ERROR`). content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' /acknowledgeMessage: post: @@ -501,13 +564,16 @@ paths: messageIds: type: array minItems: 1 + maxItems: 1000 items: type: string + minLength: 1 + maxLength: 256 description: Array of message IDs to acknowledge and delete. examples: - - ["3301", "3302"] + - ['3301', '3302'] responses: - "200": + '200': description: Messages acknowledged (deleted) successfully. content: application/json: @@ -518,26 +584,26 @@ paths: status: type: string enum: [success] - "400": + '400': description: | Missing or invalid input, or no matching messages found. Codes: `ERR_MESSAGE_ID_REQUIRED`, `ERR_INVALID_MESSAGE_ID`, `ERR_INVALID_ACKNOWLEDGMENT` content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - "401": + $ref: '#/components/schemas/ErrorResponse' + '401': description: Authentication required. content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - "500": + $ref: '#/components/schemas/ErrorResponse' + '500': description: Internal server error (`ERR_INTERNAL_ERROR`). content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' # --------------------------------------------------------------------------- # Device registration @@ -547,10 +613,10 @@ paths: operationId: registerDevice summary: Register a device for FCM push notifications description: | - Stores or updates an FCM token for the authenticated identity. If the - token already exists it is updated (identity key, device ID, platform, - last_used timestamp). The FCM token is unique-indexed; upsert semantics - are applied via ON CONFLICT MERGE. + Stores or refreshes an FCM token for the authenticated identity. A + token already owned by another identity cannot be reassigned through + this route. Re-registering a token for the same identity updates its + device metadata and last-used timestamp. tags: [Device] requestBody: required: true @@ -562,16 +628,19 @@ paths: properties: fcmToken: type: string + minLength: 1 + maxLength: 500 description: Firebase Cloud Messaging registration token. deviceId: type: string + maxLength: 255 description: Optional caller-supplied device identifier. platform: type: string enum: [ios, android, web] description: Device platform. responses: - "200": + '200': description: Device registered (or updated) successfully. content: application/json: @@ -589,64 +658,89 @@ paths: deviceId: type: integer description: Database row ID of the registration record. - "400": + '400': description: | Invalid request parameters. Codes: `ERR_INVALID_FCM_TOKEN`, `ERR_INVALID_PLATFORM` content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - "401": + $ref: '#/components/schemas/ErrorResponse' + '401': description: Authentication required (`ERR_AUTHENTICATION_REQUIRED`). content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - "500": + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Device token is already registered to another identity. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': description: Internal server error (`ERR_DATABASE_ERROR`, `ERR_INTERNAL`). content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' /devices: get: operationId: listDevices summary: List registered devices for the authenticated identity description: | - Returns all device registrations for the authenticated user, sorted by + Returns one bounded page of device registrations for the authenticated user, sorted by `updated_at` descending. FCM tokens are truncated for security (last 10 characters prefixed with "..."). tags: [Device] + parameters: + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 100 + default: 100 + - in: query + name: offset + schema: + type: integer + minimum: 0 + maximum: 100000 + default: 0 responses: - "200": + '200': description: Device list retrieved successfully. content: application/json: schema: type: object - required: [status, devices] + required: [status, limit, offset, devices] properties: status: type: string enum: [success] + limit: + type: integer + offset: + type: integer devices: type: array items: - $ref: "#/components/schemas/RegisteredDevice" - "401": + $ref: '#/components/schemas/RegisteredDevice' + '401': description: Authentication required (`ERR_AUTHENTICATION_REQUIRED`). content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - "500": + $ref: '#/components/schemas/ErrorResponse' + '500': description: Internal server error (`ERR_DATABASE_ERROR`, `ERR_INTERNAL`). content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' # --------------------------------------------------------------------------- # Permission management @@ -673,16 +767,20 @@ paths: required: [messageBox, recipientFee] properties: sender: - $ref: "#/components/schemas/PubKeyHex" + $ref: '#/components/schemas/PubKeyHex' description: Optional. If omitted, sets a box-wide default. messageBox: type: string + minLength: 1 + maxLength: 128 description: Message box name (e.g. `notifications`, `inbox`). recipientFee: type: integer - description: "-1 = blocked, 0 = always allow, positive = satoshi amount." + minimum: -1 + maximum: 2147483647 + description: '-1 = blocked, 0 = always allow, positive = satoshi amount.' responses: - "200": + '200': description: Permission set or updated successfully. content: application/json: @@ -696,7 +794,7 @@ paths: description: type: string description: Human-readable confirmation of the new policy. - "400": + '400': description: | Invalid request. Codes: `ERR_INVALID_REQUEST`, `ERR_INVALID_PUBLIC_KEY`, `ERR_INVALID_FEE_VALUE`, @@ -704,19 +802,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - "401": + $ref: '#/components/schemas/ErrorResponse' + '401': description: Authentication required (`ERR_AUTHENTICATION_REQUIRED`). content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - "500": + $ref: '#/components/schemas/ErrorResponse' + '500': description: Internal server error (`ERR_DATABASE_ERROR`, `ERR_INTERNAL`). content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' /permissions/get: get: @@ -725,24 +823,25 @@ paths: description: | Retrieves the permission record for a specific sender + message box pair owned by the authenticated user. If `sender` is omitted, returns the - box-wide default. Returns `permission: undefined` (null in JSON) when no - record is set. + box-wide default. Returns `permission: null` when no record is set. tags: [Permissions] parameters: - in: query name: sender required: false schema: - $ref: "#/components/schemas/PubKeyHex" + $ref: '#/components/schemas/PubKeyHex' description: Identity key of the sender to look up. Omit for box-wide default. - in: query name: messageBox required: true schema: type: string + minLength: 1 + maxLength: 128 description: Message box type to check. responses: - "200": + '200': description: Permission found or confirmed absent. content: application/json: @@ -757,40 +856,40 @@ paths: type: string permission: oneOf: - - $ref: "#/components/schemas/PermissionRecord" - - type: "null" - description: The permission record, or null/undefined if not set. - "400": + - $ref: '#/components/schemas/PermissionRecord' + - type: 'null' + description: The permission record, or null if not set. + '400': description: | Missing or invalid parameters. Codes: `ERR_MISSING_PARAMETERS`, `ERR_INVALID_PUBLIC_KEY` content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - "401": + $ref: '#/components/schemas/ErrorResponse' + '401': description: Authentication required (`ERR_AUTHENTICATION_REQUIRED`). content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - "500": + $ref: '#/components/schemas/ErrorResponse' + '500': description: Internal server error (`ERR_INTERNAL`). content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' /permissions/list: get: operationId: listPermissions - summary: List all message permissions for the authenticated user + summary: List message permissions for the authenticated user description: | - Returns all permission records owned by the authenticated identity with - optional filtering and pagination. Results are ordered by - `(message_box ASC, sender ASC NULLS FIRST, created_at DESC/ASC)`. - Box-wide defaults (sender = null) appear before sender-specific entries - within each message box. + Returns one bounded page of permission records owned by the + authenticated identity, with optional filtering. Results are ordered by + `(message_box ASC, normalized sender scope ASC, created_at DESC/ASC)`. + Box-wide defaults (sender = null) use the empty normalized scope and + therefore appear before sender-specific entries within each message box. tags: [Permissions] parameters: - in: query @@ -805,7 +904,7 @@ paths: schema: type: integer minimum: 1 - maximum: 1000 + maximum: 100 default: 100 description: Maximum number of records to return. - in: query @@ -814,6 +913,7 @@ paths: schema: type: integer minimum: 0 + maximum: 100000 default: 0 description: Number of records to skip (for pagination). - in: query @@ -825,7 +925,7 @@ paths: default: desc description: Sort order for `created_at` (newest first by default). responses: - "200": + '200': description: Permission list retrieved. content: application/json: @@ -839,30 +939,30 @@ paths: permissions: type: array items: - $ref: "#/components/schemas/PermissionRecord" + $ref: '#/components/schemas/PermissionRecord' totalCount: type: integer description: Total count before pagination (useful for building page controls). - "400": + '400': description: | Invalid pagination parameters. Codes: `ERR_INVALID_LIMIT`, `ERR_INVALID_OFFSET` content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - "401": + $ref: '#/components/schemas/ErrorResponse' + '401': description: Authentication required (`ERR_UNAUTHORIZED`). content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - "500": + $ref: '#/components/schemas/ErrorResponse' + '500': description: Internal server error (`ERR_LIST_PERMISSIONS_FAILED`). content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' /permissions/quote: get: @@ -887,10 +987,10 @@ paths: required: true schema: oneOf: - - $ref: "#/components/schemas/PubKeyHex" + - $ref: '#/components/schemas/PubKeyHex' - type: array items: - $ref: "#/components/schemas/PubKeyHex" + $ref: '#/components/schemas/PubKeyHex' style: form explode: true description: | @@ -903,7 +1003,7 @@ paths: type: string description: Message box type to quote against. responses: - "200": + '200': description: Quote(s) generated. content: application/json: @@ -944,7 +1044,7 @@ paths: required: [recipient, messageBox, deliveryFee, recipientFee, status] properties: recipient: - $ref: "#/components/schemas/PubKeyHex" + $ref: '#/components/schemas/PubKeyHex' messageBox: type: string deliveryFee: @@ -952,7 +1052,7 @@ paths: recipientFee: type: integer status: - $ref: "#/components/schemas/PermissionStatus" + $ref: '#/components/schemas/PermissionStatus' totals: type: object required: [deliveryFees, recipientFees, totalForPayableRecipients] @@ -966,27 +1066,27 @@ paths: blockedRecipients: type: array items: - $ref: "#/components/schemas/PubKeyHex" - "400": + $ref: '#/components/schemas/PubKeyHex' + '400': description: | Missing or invalid parameters. Codes: `ERR_MISSING_PARAMETERS`, `ERR_INVALID_PUBLIC_KEY` content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - "401": + $ref: '#/components/schemas/ErrorResponse' + '401': description: Authentication required (`ERR_AUTHENTICATION_REQUIRED`). content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - "500": + $ref: '#/components/schemas/ErrorResponse' + '500': description: Internal server error (`ERR_INTERNAL`). content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' # --------------------------------------------------------------------------- # Swagger / docs (informational — not tested in contract tests) @@ -999,7 +1099,7 @@ paths: tags: [Meta] security: [] responses: - "200": + '200': description: HTML page. /openapi.json: @@ -1010,7 +1110,7 @@ paths: tags: [Meta] security: [] responses: - "200": + '200': description: OpenAPI 3.0 document. content: application/json: From 91f638a8f428c54b0da63fd505ba978e5367b44f Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sun, 26 Jul 2026 11:21:26 -0700 Subject: [PATCH 2/2] Resolve Message Box quality and coverage findings --- infra/message-box-server/jest.config.mjs | 2 +- .../message-box-server/src/config/runtime.ts | 2 +- infra/message-box-server/src/index.ts | 6 +- .../src/routes/permissions/getQuote.ts | 307 ++--- .../src/MessageBoxClient.ts | 119 +- .../__tests/MessageBoxClientHardening.test.ts | 1052 +++++++++++++++++ .../src/__tests/PeerPayClientUnit.test.ts | 141 +++ .../src/__tests/PeerTokenClientUnit.test.ts | 313 ++++- .../src/__tests/host.test.ts | 26 + .../messaging/message-box-client/src/host.ts | 5 +- 10 files changed, 1782 insertions(+), 191 deletions(-) create mode 100644 packages/messaging/message-box-client/src/__tests/MessageBoxClientHardening.test.ts diff --git a/infra/message-box-server/jest.config.mjs b/infra/message-box-server/jest.config.mjs index 827a8814e..9985466de 100644 --- a/infra/message-box-server/jest.config.mjs +++ b/infra/message-box-server/jest.config.mjs @@ -32,7 +32,7 @@ export default { modulePathIgnorePatterns: ['/out/'], // Important for ES modules - transformIgnorePatterns: ['/node_modules/(?!.*\\.mjs$)'], + transformIgnorePatterns: [String.raw`/node_modules/(?!.*\.mjs$)`], // Use .mjs extension for Jest config to indicate ESM moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node', 'mjs'] diff --git a/infra/message-box-server/src/config/runtime.ts b/infra/message-box-server/src/config/runtime.ts index bb97fd057..3ff8064d0 100644 --- a/infra/message-box-server/src/config/runtime.ts +++ b/infra/message-box-server/src/config/runtime.ts @@ -8,7 +8,7 @@ interface HttpPortEnvironment { function parsePort(value: string | undefined, name: string): number | undefined { if (value == null || value.trim() === '') return undefined - if (!/^[0-9]+$/.test(value.trim())) { + if (!/^\d+$/.test(value.trim())) { throw new Error(`${name} must be an integer between 1 and ${MAX_TCP_PORT}.`) } const parsed = Number(value) diff --git a/infra/message-box-server/src/index.ts b/infra/message-box-server/src/index.ts index a5cfad06f..11770da35 100644 --- a/infra/message-box-server/src/index.ts +++ b/infra/message-box-server/src/index.ts @@ -142,7 +142,9 @@ export async function startStandalone(): Promise { // the service accepts traffic. A failed prerequisite is a failed process, not // a partially healthy server. if (NODE_ENV !== 'test') { - void startStandalone().catch(async error => { + try { + await startStandalone() + } catch (error) { log.error({ operation: 'server.init', outcome: 'error', err: error }, '[SERVER INIT ERROR]') try { await knex.destroy() @@ -153,7 +155,7 @@ if (NODE_ENV !== 'test') { ) } process.exitCode = 1 - }) + } } // Composable API for embedding (standalone boot above is unchanged) diff --git a/infra/message-box-server/src/routes/permissions/getQuote.ts b/infra/message-box-server/src/routes/permissions/getQuote.ts index 32af211ec..f0b91567c 100644 --- a/infra/message-box-server/src/routes/permissions/getQuote.ts +++ b/infra/message-box-server/src/routes/permissions/getQuote.ts @@ -15,6 +15,166 @@ export interface GetQuoteRequest extends AuthRequest { } } +type QuoteStatus = 'blocked' | 'always_allow' | 'payment_required' + +interface QuoteValidationError { + statusCode: 400 | 401 + body: { + status: 'error' + code: string + description: string + } +} + +interface ValidatedQuoteInput { + sender: string + recipients: string[] + messageBox: string +} + +interface RecipientQuote { + recipient: string + messageBox: string + deliveryFee: number + recipientFee: number + status: QuoteStatus +} + +function validationError( + statusCode: 400 | 401, + code: string, + description: string +): QuoteValidationError { + return { statusCode, body: { status: 'error', code, description } } +} + +function invalidRecipientIndexes(recipients: string[]): number[] { + const invalidIndexes: number[] = [] + recipients.forEach((recipient, index) => { + try { + PublicKey.fromString(recipient) + } catch { + invalidIndexes.push(index) + } + }) + return invalidIndexes +} + +function validateQuoteInput(req: GetQuoteRequest): ValidatedQuoteInput | QuoteValidationError { + const sender = req.auth?.identityKey + if (sender == null) { + Logger.log('[DEBUG] Authentication required for message quote') + return validationError(401, 'ERR_AUTHENTICATION_REQUIRED', 'Authentication required.') + } + + const { recipient, messageBox } = req.query + if (recipient == null || messageBox == null) { + Logger.log('[DEBUG] Missing required parameters for message quote') + return validationError( + 400, + 'ERR_MISSING_PARAMETERS', + 'recipient and messageBox parameters are required.' + ) + } + if ( + typeof messageBox !== 'string' || + messageBox.trim() === '' || + Buffer.byteLength(messageBox, 'utf8') > MAX_MESSAGE_BOX_BYTES + ) { + return validationError( + 400, + 'ERR_INVALID_MESSAGE_BOX', + `messageBox must be a non-empty string of at most ${MAX_MESSAGE_BOX_BYTES} bytes.` + ) + } + + const recipients = Array.isArray(recipient) ? recipient : [recipient] + if (recipients.length === 0) { + return validationError(400, 'ERR_MISSING_PARAMETERS', 'At least one recipient is required.') + } + if (recipients.length > MAX_QUOTE_RECIPIENTS) { + return validationError( + 400, + 'ERR_TOO_MANY_RECIPIENTS', + `A quote may include at most ${MAX_QUOTE_RECIPIENTS} recipients.` + ) + } + + const invalidIndexes = invalidRecipientIndexes(recipients) + if (invalidIndexes.length > 0) { + Logger.log('[DEBUG] Invalid recipient public key format in array') + return validationError( + 400, + 'ERR_INVALID_PUBLIC_KEY', + `Invalid recipient public key at index(es): ${invalidIndexes.join(', ')}.` + ) + } + + return { sender, recipients, messageBox } +} + +function isQuoteValidationError( + input: ValidatedQuoteInput | QuoteValidationError +): input is QuoteValidationError { + return 'statusCode' in input +} + +function feeToStatus(fee: number): QuoteStatus { + if (fee === -1) return 'blocked' + if (fee === 0) return 'always_allow' + return 'payment_required' +} + +async function buildMultiRecipientQuote( + input: ValidatedQuoteInput, + deliveryFee: number +): Promise<{ + quotesByRecipient: RecipientQuote[] + blockedRecipients: string[] + totals: { + deliveryFees: number + recipientFees: number + totalForPayableRecipients: number + } +}> { + const quotesByRecipient: RecipientQuote[] = [] + const blockedRecipients: string[] = [] + let totalRecipientFees = 0 + + for (let offset = 0; offset < input.recipients.length; offset += QUOTE_CONCURRENCY) { + const batch = input.recipients.slice(offset, offset + QUOTE_CONCURRENCY) + const recipientFees = await Promise.all( + batch.map(async recipient => ({ + recipient, + recipientFee: await getRecipientFee(recipient, input.sender, input.messageBox) + })) + ) + + for (const { recipient, recipientFee } of recipientFees) { + quotesByRecipient.push({ + recipient, + messageBox: input.messageBox, + deliveryFee, + recipientFee, + status: feeToStatus(recipientFee) + }) + if (recipientFee === -1) blockedRecipients.push(recipient) + else totalRecipientFees += recipientFee + } + } + + const deliveryFees = deliveryFee * input.recipients.length + return { + quotesByRecipient, + blockedRecipients, + totals: { + deliveryFees, + recipientFees: totalRecipientFees, + totalForPayableRecipients: deliveryFees + totalRecipientFees + } + } +} + /** * @swagger * /permissions/quote: @@ -59,84 +219,19 @@ export default { Logger.log('[DEBUG] Processing message quote request') log.debug({ operation: 'permissions.quote' }, 'Processing message quote request') - // Validate authentication (the caller is the SENDER) - const sender = req.auth?.identityKey - if (sender == null) { - Logger.log('[DEBUG] Authentication required for message quote') - return res.status(401).json({ - status: 'error', - code: 'ERR_AUTHENTICATION_REQUIRED', - description: 'Authentication required.' - }) - } - - const { recipient, messageBox } = req.query - - // Required params - if (recipient == null || messageBox == null) { - Logger.log('[DEBUG] Missing required parameters for message quote') - return res.status(400).json({ - status: 'error', - code: 'ERR_MISSING_PARAMETERS', - description: 'recipient and messageBox parameters are required.' - }) - } - if ( - typeof messageBox !== 'string' || - messageBox.trim() === '' || - Buffer.byteLength(messageBox, 'utf8') > MAX_MESSAGE_BOX_BYTES - ) { - return res.status(400).json({ - status: 'error', - code: 'ERR_INVALID_MESSAGE_BOX', - description: `messageBox must be a non-empty string of at most ${MAX_MESSAGE_BOX_BYTES} bytes.` - }) - } - - // Normalize recipients to array (preserve order) - const recipients: string[] = Array.isArray(recipient) ? recipient : [recipient] - - if (recipients.length === 0) { - return res.status(400).json({ - status: 'error', - code: 'ERR_MISSING_PARAMETERS', - description: 'At least one recipient is required.' - }) - } - - if (recipients.length > MAX_QUOTE_RECIPIENTS) { - return res.status(400).json({ - status: 'error', - code: 'ERR_TOO_MANY_RECIPIENTS', - description: `A quote may include at most ${MAX_QUOTE_RECIPIENTS} recipients.` - }) - } - - // Validate each recipient public key - const invalidIdx: number[] = [] - for (let i = 0; i < recipients.length; i++) { - try { - PublicKey.fromString(recipients[i]) - } catch { - invalidIdx.push(i) - } - } - if (invalidIdx.length > 0) { - Logger.log('[DEBUG] Invalid recipient public key format in array') - return res.status(400).json({ - status: 'error', - code: 'ERR_INVALID_PUBLIC_KEY', - description: `Invalid recipient public key at index(es): ${invalidIdx.join(', ')}.` - }) - } + const input = validateQuoteInput(req) + if (isQuoteValidationError(input)) return res.status(input.statusCode).json(input.body) // Delivery fee for this messageBox (applies per message/recipient) - const perMessageDeliveryFee = await getServerDeliveryFee(messageBox) + const perMessageDeliveryFee = await getServerDeliveryFee(input.messageBox) // Single-recipient path → keep legacy response shape for compatibility - if (recipients.length === 1) { - const recipientKey = recipients[0] - const recipientFee = await getRecipientFee(recipientKey, sender, messageBox) + if (input.recipients.length === 1) { + const recipientFee = await getRecipientFee( + input.recipients[0], + input.sender, + input.messageBox + ) return res.status(200).json({ status: 'success', @@ -148,70 +243,12 @@ export default { }) } - // Multi-recipient path → compute per-recipient, plus aggregates - const quotesByRecipient: Array<{ - recipient: string - messageBox: string - deliveryFee: number - recipientFee: number - status: 'blocked' | 'always_allow' | 'payment_required' - }> = [] - - const blockedRecipients: string[] = [] - let totalRecipientFees = 0 - let totalDeliveryFees = 0 - - // Helper to map fee -> status - const feeToStatus = (fee: number): 'blocked' | 'always_allow' | 'payment_required' => { - if (fee === -1) return 'blocked' - if (fee === 0) return 'always_allow' - return 'payment_required' - } - - for (let offset = 0; offset < MAX_QUOTE_RECIPIENTS; offset += QUOTE_CONCURRENCY) { - const batch = recipients.slice(offset, offset + QUOTE_CONCURRENCY) - if (batch.length === 0) break - - const recipientFees = await Promise.all( - batch.map(async recipientKey => ({ - recipient: recipientKey, - recipientFee: await getRecipientFee(recipientKey, sender, messageBox) - })) - ) - - for (const { recipient: recipientKey, recipientFee } of recipientFees) { - const status = feeToStatus(recipientFee) - quotesByRecipient.push({ - recipient: recipientKey, - messageBox, - deliveryFee: perMessageDeliveryFee, - recipientFee, - status - }) - - totalDeliveryFees += perMessageDeliveryFee - if (recipientFee === -1) { - blockedRecipients.push(recipientKey) - } else { - totalRecipientFees += recipientFee - } - } - } - - const totals = { - deliveryFees: totalDeliveryFees, - recipientFees: totalRecipientFees, - // If any are blocked, caller may want to handle those separately. - // We still provide a full monetary total for non-blocked recipients: - totalForPayableRecipients: totalDeliveryFees + totalRecipientFees - } + const quote = await buildMultiRecipientQuote(input, perMessageDeliveryFee) return res.status(200).json({ status: 'success', - description: `Message delivery quotes generated for ${recipients.length} recipients.`, - quotesByRecipient, - totals, - blockedRecipients + description: `Message delivery quotes generated for ${input.recipients.length} recipients.`, + ...quote }) } catch (error) { Logger.error('[ERROR] Internal Server Error in message quote:', error) diff --git a/packages/messaging/message-box-client/src/MessageBoxClient.ts b/packages/messaging/message-box-client/src/MessageBoxClient.ts index e63642f19..1c1911f74 100644 --- a/packages/messaging/message-box-client/src/MessageBoxClient.ts +++ b/packages/messaging/message-box-client/src/MessageBoxClient.ts @@ -99,6 +99,63 @@ function buildBatchSendResult( } } +function assertBatchSendParams(params: SendListParams): void { + if (!Array.isArray(params.recipients) || params.recipients.length === 0) { + throw new Error('You must provide at least one recipient!') + } + if (params.recipients.length > 100) { + throw new Error('A batch may include at most 100 recipients.') + } + if (!params.messageBox || params.messageBox.trim() === '') { + throw new Error('You must provide a messageBox to send this message into!') + } + if (params.body == null || (typeof params.body === 'string' && params.body.trim().length === 0)) { + throw new Error('Every message must have a body!') + } + if (params.skipEncryption !== true) { + throw new Error( + 'A shared multi-recipient batch cannot be encrypted per recipient. ' + + 'Set skipEncryption: true explicitly or send encrypted messages individually.' + ) + } +} + +function buildRecipientQuoteMap( + quotes: MessageBoxMultiQuote['quotesByRecipient'] +): Map { + return new Map( + quotes.map(quote => [ + quote.recipient, + { + recipientFee: quote.recipientFee, + deliveryFee: quote.deliveryFee + } + ]) + ) +} + +function selectDeliveryAgentIdentityKey( + identityKeysByHost: Record | undefined, + finalHost: string, + hasOverrideHost: boolean +): string { + const entries = Object.entries(identityKeysByHost ?? {}) + if (entries.length === 0) { + throw new Error('Missing delivery agent identity keys in quote response.') + } + if (entries.length > 1 && !hasOverrideHost) { + throw new Error( + 'Recipients resolve to multiple hosts. Use overrideHost to force a single server or split by host.' + ) + } + + const identityKey = identityKeysByHost?.[finalHost] ?? entries[0][1] + if (!identityKey) { + throw new Error('Could not determine server delivery agent identity key.') + } + return identityKey +} + type MessageBoxRecipientQuote = MessageBoxMultiQuote['quotesByRecipient'][number] type MessageBoxQuoteStatus = MessageBoxRecipientQuote['status'] @@ -1265,26 +1322,9 @@ export class MessageBoxClient { overrideHost?: string ): Promise { await this.assertInitialized() + assertBatchSendParams(params) const { recipients, messageBox, body } = params - if (!Array.isArray(recipients) || recipients.length === 0) { - throw new Error('You must provide at least one recipient!') - } - if (recipients.length > 100) { - throw new Error('A batch may include at most 100 recipients.') - } - if (!messageBox || messageBox.trim() === '') { - throw new Error('You must provide a messageBox to send this message into!') - } - if (body == null || (typeof body === 'string' && body.trim().length === 0)) { - throw new Error('Every message must have a body!') - } - if (params.skipEncryption !== true) { - throw new Error( - 'A shared multi-recipient batch cannot be encrypted per recipient. ' + - 'Set skipEncryption: true explicitly or send encrypted messages individually.' - ) - } // 1) Multi-quote for all recipients const quoteResponse = (await this.getMessageBoxQuote( @@ -1316,39 +1356,20 @@ export class MessageBoxClient { } // 3) Map recipient -> fees - const perRecipientQuotes = new Map() - for (const q of quotesByRecipient) { - perRecipientQuotes.set(q.recipient, { - recipientFee: q.recipientFee, - deliveryFee: q.deliveryFee - }) - } + const perRecipientQuotes = buildRecipientQuoteMap(quotesByRecipient) // 4) One delivery agent only (batch goes to one server) const { deliveryAgentIdentityKeyByHost } = quoteResponse - if ( - !deliveryAgentIdentityKeyByHost || - Object.keys(deliveryAgentIdentityKeyByHost).length === 0 - ) { - throw new Error('Missing delivery agent identity keys in quote response.') - } - if (Object.keys(deliveryAgentIdentityKeyByHost).length > 1 && !overrideHost) { - // To keep the single-POST invariant, we require all recipients to share a host - throw new Error( - 'Recipients resolve to multiple hosts. Use overrideHost to force a single server or split by host.' - ) - } // pick the host to POST to const finalHost = normalizeMessageBoxHost( overrideHost ?? (await this.resolveHostForRecipient(allowedRecipients[0])) ) - const singleDeliveryKey = - deliveryAgentIdentityKeyByHost[finalHost] ?? Object.values(deliveryAgentIdentityKeyByHost)[0] - - if (!singleDeliveryKey) { - throw new Error('Could not determine server delivery agent identity key.') - } + const singleDeliveryKey = selectDeliveryAgentIdentityKey( + deliveryAgentIdentityKeyByHost, + finalHost, + overrideHost != null + ) // 5) Identity key (sender) if (!this.myIdentityKey) { @@ -1727,9 +1748,7 @@ export class MessageBoxClient { host, acceptPayments }: ListMessagesParams): Promise { - if (typeof acceptPayments !== 'boolean') { - acceptPayments = true - } + const shouldAcceptPayments = acceptPayments !== false if (typeof messageBox !== 'string' || messageBox.trim() === '') { throw new Error('MessageBox cannot be empty') } @@ -1782,7 +1801,7 @@ export class MessageBoxClient { const parsed = messages.map(message => this.parseMessageEnvelope(message)) - if (acceptPayments) { + if (shouldAcceptPayments) { const paymentJobs = parsed.filter( p => p.paymentData?.tx != null && p.paymentData.outputs != null ) @@ -1895,7 +1914,7 @@ export class MessageBoxClient { throw new Error(data.description ?? 'Unknown server error') } if (!Array.isArray(data.messages)) { - throw new Error('Message Box server returned an invalid messages payload') + throw new TypeError('Message Box server returned an invalid messages payload') } messages.push(...(data.messages as PeerMessage[])) @@ -2550,7 +2569,9 @@ export class MessageBoxClient { } if (!Array.isArray(data.permissions)) { - throw new Error('Failed to list permissions: server returned an invalid permissions payload') + throw new TypeError( + 'Failed to list permissions: server returned an invalid permissions payload' + ) } return data.permissions.map((permission: unknown) => { diff --git a/packages/messaging/message-box-client/src/__tests/MessageBoxClientHardening.test.ts b/packages/messaging/message-box-client/src/__tests/MessageBoxClientHardening.test.ts new file mode 100644 index 000000000..f16fe3893 --- /dev/null +++ b/packages/messaging/message-box-client/src/__tests/MessageBoxClientHardening.test.ts @@ -0,0 +1,1052 @@ +import { PrivateKey, type WalletInterface } from '@bsv/sdk' +import { jest } from '@jest/globals' +import { MessageBoxClient } from '../MessageBoxClient.js' +import type { SendListParams } from '../types/permissions.js' + +const identityKey = PrivateKey.fromRandom().toPublicKey().toString() +const recipientA = PrivateKey.fromRandom().toPublicKey().toString() +const recipientB = PrivateKey.fromRandom().toPublicKey().toString() + +function jsonResponse(body: unknown, init: Partial = {}): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'x-bsv-auth-identity-key': identityKey }), + bodyUsed: false, + json: async () => body, + ...init + } as Response +} + +function createWallet(): jest.Mocked { + return { + getPublicKey: jest.fn().mockResolvedValue({ publicKey: identityKey }), + createHmac: jest.fn().mockResolvedValue({ hmac: [1, 2, 3] }), + encrypt: jest.fn().mockResolvedValue({ ciphertext: [1, 2, 3] }), + decrypt: jest.fn().mockResolvedValue({ + plaintext: Array.from(Buffer.from('{"secret":true}', 'utf8')) + }), + createAction: jest.fn().mockResolvedValue({ tx: [1, 2, 3] }), + internalizeAction: jest.fn().mockResolvedValue({ accepted: true }) + } as unknown as jest.Mocked +} + +describe('MessageBoxClient hardening branches', () => { + let client: MessageBoxClient + let wallet: jest.Mocked + let fetchMock: jest.SpiedFunction + + beforeEach(() => { + wallet = createWallet() + client = new MessageBoxClient({ + host: 'https://message-box.example/api', + walletClient: wallet + }) + fetchMock = jest.spyOn(client.authFetch, 'fetch') + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('batch sends', () => { + it.each([ + [ + { recipients: [], messageBox: 'inbox', body: 'hello', skipEncryption: true }, + 'at least one recipient' + ], + [ + { + recipients: Array.from({ length: 101 }, () => recipientA), + messageBox: 'inbox', + body: 'hello', + skipEncryption: true + }, + 'at most 100 recipients' + ], + [ + { recipients: [recipientA], messageBox: ' ', body: 'hello', skipEncryption: true }, + 'provide a messageBox' + ], + [ + { recipients: [recipientA], messageBox: 'inbox', body: ' ', skipEncryption: true }, + 'must have a body' + ], + [ + { recipients: [recipientA], messageBox: 'inbox', body: null, skipEncryption: true }, + 'must have a body' + ], + [{ recipients: [recipientA], messageBox: 'inbox', body: 'hello' }, 'Set skipEncryption: true'] + ])('rejects invalid batch input %#', async (params, error) => { + await expect( + client.sendMessageToRecipients(params as unknown as SendListParams) + ).rejects.toThrow(error) + }) + + it('returns a structured result when every recipient is blocked', async () => { + jest.spyOn(client, 'getMessageBoxQuote').mockResolvedValue({ + quotesByRecipient: [], + blockedRecipients: [recipientA, recipientB], + deliveryAgentIdentityKeyByHost: undefined as never, + totals: { deliveryFees: 0, recipientFees: 0, totalForPayableRecipients: 0 } + }) + + await expect( + client.sendMessageToRecipients({ + recipients: [recipientA, recipientB], + messageBox: 'inbox', + body: 'hello', + skipEncryption: true + }) + ).resolves.toMatchObject({ + status: 'error', + sent: [], + blocked: [recipientA, recipientB], + failed: [ + { recipient: recipientA, error: 'blocked' }, + { recipient: recipientB, error: 'blocked' } + ] + }) + }) + + it('rejects missing delivery identities and ambiguous multi-host quotes', async () => { + const quote = jest.spyOn(client, 'getMessageBoxQuote') + quote.mockResolvedValueOnce({ + quotesByRecipient: [ + { + recipient: recipientA, + messageBox: 'inbox', + recipientFee: 0, + deliveryFee: 0, + status: 'always_allow' + } + ], + blockedRecipients: undefined as never, + deliveryAgentIdentityKeyByHost: undefined as never, + totals: { deliveryFees: 0, recipientFees: 0, totalForPayableRecipients: 0 } + }) + + const params: SendListParams = { + recipients: [recipientA], + messageBox: 'inbox', + body: 'hello', + skipEncryption: true + } + await expect( + client.sendMessageToRecipients(params, 'https://message-box.example/api') + ).rejects.toThrow('Missing delivery agent identity keys') + + quote.mockResolvedValueOnce({ + quotesByRecipient: [ + { + recipient: recipientA, + messageBox: 'inbox', + recipientFee: 0, + deliveryFee: 0, + status: 'always_allow' + } + ], + blockedRecipients: [], + deliveryAgentIdentityKeyByHost: { + 'https://one.example': identityKey, + 'https://two.example': identityKey + }, + totals: { deliveryFees: 0, recipientFees: 0, totalForPayableRecipients: 0 } + }) + jest.spyOn(client, 'resolveHostForRecipient').mockResolvedValue('https://one.example') + + await expect(client.sendMessageToRecipients(params)).rejects.toThrow( + 'Recipients resolve to multiple hosts' + ) + + quote.mockResolvedValueOnce({ + quotesByRecipient: [ + { + recipient: recipientA, + messageBox: 'inbox', + recipientFee: 0, + deliveryFee: 0, + status: 'always_allow' + } + ], + blockedRecipients: [], + deliveryAgentIdentityKeyByHost: { + 'https://message-box.example/api': '' + }, + totals: { deliveryFees: 0, recipientFees: 0, totalForPayableRecipients: 0 } + }) + await expect( + client.sendMessageToRecipients(params, 'https://message-box.example/api') + ).rejects.toThrow('Could not determine server delivery agent identity key') + }) + + it('reports success, partial success, and zero-result server responses', async () => { + jest.spyOn(client, 'getMessageBoxQuote').mockResolvedValue({ + quotesByRecipient: [recipientA, recipientB].map(recipient => ({ + recipient, + messageBox: 'inbox', + recipientFee: 0, + deliveryFee: 1, + status: 'always_allow' as const + })), + blockedRecipients: [], + deliveryAgentIdentityKeyByHost: { + 'https://different.example': identityKey + }, + totals: { deliveryFees: 2, recipientFees: 0, totalForPayableRecipients: 2 } + }) + jest.spyOn(client as any, 'createMessagePaymentBatch').mockResolvedValue({ + tx: [1, 2, 3], + outputs: [], + description: 'batch' + }) + fetchMock + .mockResolvedValueOnce( + jsonResponse({ status: 'success', results: [{ recipientA }, { recipientB }] }) + ) + .mockResolvedValueOnce(jsonResponse({ status: 'success', results: [{ recipientA }] })) + .mockResolvedValueOnce(jsonResponse({ status: 'success', results: [] })) + + const params: SendListParams = { + recipients: [recipientA, recipientB], + messageBox: 'inbox', + body: { hello: 'world' }, + skipEncryption: true + } + const overrideHost = 'https://message-box.example/api' + + await expect(client.sendMessageToRecipients(params, overrideHost)).resolves.toMatchObject({ + status: 'success' + }) + await expect(client.sendMessageToRecipients(params, overrideHost)).resolves.toMatchObject({ + status: 'partial' + }) + await expect(client.sendMessageToRecipients(params, overrideHost)).resolves.toMatchObject({ + status: 'error' + }) + + const sentBody = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)) + expect(sentBody.message).toMatchObject({ + recipients: [recipientA, recipientB], + messageId: ['010203', '010203'], + body: '{"hello":"world"}' + }) + }) + + it('returns recipient failures when the batch endpoint rejects the request', async () => { + jest.spyOn(client, 'getMessageBoxQuote').mockResolvedValue({ + quotesByRecipient: [ + { + recipient: recipientA, + messageBox: 'inbox', + recipientFee: 0, + deliveryFee: 1, + status: 'always_allow' + } + ], + blockedRecipients: [], + deliveryAgentIdentityKeyByHost: { + 'https://message-box.example/api': identityKey + }, + totals: { deliveryFees: 1, recipientFees: 0, totalForPayableRecipients: 1 } + }) + jest.spyOn(client as any, 'createMessagePaymentBatch').mockResolvedValue({ + tx: [1], + outputs: [], + description: 'batch' + }) + fetchMock.mockResolvedValue( + jsonResponse( + { status: 'error', description: 'blocked by server' }, + { ok: true, status: 200 } + ) + ) + + await expect( + client.sendMessageToRecipients( + { + recipients: [recipientA], + messageBox: 'inbox', + body: 'hello', + skipEncryption: true + }, + 'https://message-box.example/api' + ) + ).resolves.toMatchObject({ + status: 'error', + failed: [{ recipient: recipientA, error: 'blocked by server' }] + }) + + fetchMock.mockResolvedValueOnce( + jsonResponse({}, { ok: false, status: 503, statusText: 'Unavailable' }) + ) + await expect( + client.sendMessageToRecipients( + { + recipients: [recipientA], + messageBox: 'inbox', + body: 'hello', + skipEncryption: true + }, + 'https://message-box.example/api' + ) + ).resolves.toMatchObject({ + status: 'error', + failed: [{ recipient: recipientA, error: 'HTTP 503 - Unavailable' }] + }) + }) + + it('keeps the deprecated batch spelling as a forwarding alias', async () => { + const send = jest.spyOn(client, 'sendMessageToRecipients').mockResolvedValue({ + status: 'success', + description: 'ok', + sent: [], + blocked: [], + failed: [] + }) + const params = { + recipients: [recipientA], + messageBox: 'inbox', + body: 'hello', + skipEncryption: true + } + + await client.sendMesagetoRecepients(params, 'https://override.example') + expect(send).toHaveBeenCalledWith(params, 'https://override.example') + }) + }) + + describe('message payment construction', () => { + it('constructs delivery and recipient outputs for a paid message', async () => { + const payment = await (client as any).createMessagePayment(recipientA, { + recipientFee: 2, + deliveryFee: 3, + deliveryAgentIdentityKey: identityKey + }) + + expect(payment.tx).toEqual([1, 2, 3]) + expect(payment.outputs).toHaveLength(2) + expect(wallet.createAction).toHaveBeenCalledWith( + expect.objectContaining({ outputs: expect.arrayContaining([expect.any(Object)]) }), + undefined + ) + }) + + it('rejects a zero-fee payment and a wallet action without a transaction', async () => { + await expect( + (client as any).createMessagePayment(recipientA, { + recipientFee: 0, + deliveryFee: 0, + deliveryAgentIdentityKey: identityKey + }) + ).rejects.toThrow('No payment required') + + wallet.createAction.mockResolvedValueOnce({} as never) + await expect( + (client as any).createMessagePayment(recipientA, { + recipientFee: 1, + deliveryFee: 0, + deliveryAgentIdentityKey: identityKey + }) + ).rejects.toThrow('Failed to create payment transaction') + }) + + it('constructs a batch payment and skips missing or zero-fee recipient quotes', async () => { + const payment = await (client as any).createMessagePaymentBatch( + [recipientA, recipientB, identityKey], + new Map([ + [recipientA, { recipientFee: 2, deliveryFee: 3 }], + [recipientB, { recipientFee: 0, deliveryFee: 3 }] + ]), + identityKey + ) + + expect(payment.tx).toEqual([1, 2, 3]) + expect(payment.outputs).toHaveLength(2) + }) + }) + + describe('message listing', () => { + it('queries the overlay with explicit and current identity keys', async () => { + const query = jest + .spyOn((client as any).lookupResolver, 'query') + .mockResolvedValue({ type: 'output-list', outputs: [] }) + + await expect(client.queryAdvertisements()).resolves.toEqual([]) + await expect( + client.queryAdvertisements(identityKey, 'https://filter.example') + ).resolves.toEqual([]) + expect(query).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + query: { identityKey, host: 'https://filter.example' } + }) + ) + }) + + it('internalizes payments, decrypts envelopes, and sorts messages newest first', async () => { + const payment = { + tx: [1, 2, 3], + outputs: [ + { + outputIndex: 0, + protocol: 'wallet payment', + paymentRemittance: { + derivationPrefix: 'prefix', + derivationSuffix: 'suffix', + senderIdentityKey: identityKey + } + } + ], + description: 'recipient payment' + } + fetchMock.mockResolvedValue( + jsonResponse({ + status: 'success', + hasMore: false, + messages: [ + { + messageId: 'older', + sender: recipientA, + timestamp: 1, + body: JSON.stringify({ + message: JSON.stringify({ + encryptedMessage: Buffer.from([1, 2, 3]).toString('base64') + }), + payment + }) + }, + { + messageId: 'newer', + sender: recipientB, + timestamp: 2, + body: JSON.stringify({ message: { plain: true } }) + } + ] + }) + ) + + const messages = await client.listMessages({ + messageBox: 'inbox', + host: 'https://message-box.example/api' + }) + + expect(messages.map(message => message.messageId)).toEqual(['newer', 'older']) + expect(messages[0].body).toEqual({ plain: true }) + expect(messages[1].body).toEqual({ secret: true }) + expect(wallet.internalizeAction).toHaveBeenCalled() + expect(wallet.decrypt).toHaveBeenCalled() + }) + + it('uses advertised hosts, tolerates a failed host, and deduplicates messages', async () => { + jest + .spyOn(client, 'queryAdvertisements') + .mockResolvedValue([{ host: 'https://advertised.example', txid: 'tx', outputIndex: 0 }]) + fetchMock.mockRejectedValueOnce(new Error('default host unavailable')).mockResolvedValueOnce( + jsonResponse({ + status: 'success', + messages: [ + { messageId: 'one', sender: recipientA, body: 'plain', timestamp: 1 }, + { messageId: 'one', sender: recipientA, body: 'duplicate', timestamp: 1 } + ] + }) + ) + + await expect( + client.listMessages({ messageBox: 'inbox', acceptPayments: false }) + ).resolves.toEqual([expect.objectContaining({ messageId: 'one', body: 'plain' })]) + }) + + it('rejects empty boxes and complete host failure while allowing an empty successful inbox', async () => { + await expect(client.listMessages({ messageBox: ' ' })).rejects.toThrow( + 'MessageBox cannot be empty' + ) + + fetchMock.mockRejectedValueOnce(new Error('offline')) + await expect( + client.listMessages({ messageBox: 'inbox', host: 'https://message-box.example/api' }) + ).rejects.toThrow('Failed to retrieve messages from any host') + + fetchMock.mockResolvedValueOnce( + jsonResponse({ status: 'success', messages: [], hasMore: false }) + ) + await expect( + client.listMessages({ messageBox: 'inbox', host: 'https://message-box.example/api' }) + ).resolves.toEqual([]) + }) + + it.each([ + [ + jsonResponse({}, { ok: false, status: 503, statusText: 'Unavailable' }), + 'HTTP 503 Unavailable' + ], + [jsonResponse({ status: 'error', description: 'server error' }), 'server error'], + [jsonResponse({ status: 'error' }), 'Unknown server error'], + [jsonResponse({ status: 'success', messages: 'invalid' }), 'invalid messages payload'] + ])('rejects malformed list response %#', async (response, error) => { + fetchMock.mockResolvedValue(response) + await expect( + client.listMessagesLite({ + messageBox: 'inbox', + host: 'https://message-box.example/api' + }) + ).rejects.toThrow(error) + }) + + it('bounds pagination even if a server continually claims another page', async () => { + fetchMock.mockResolvedValue(jsonResponse({ status: 'success', messages: [], hasMore: true })) + + await expect( + client.listMessagesLite({ + messageBox: 'inbox', + host: 'https://message-box.example/api' + }) + ).rejects.toThrow('pagination exceeded 100000 messages') + expect(fetchMock).toHaveBeenCalledTimes(100) + }) + + it('marks an individual message when decryption fails', async () => { + wallet.decrypt.mockRejectedValue(new Error('cannot decrypt')) + fetchMock.mockResolvedValue( + jsonResponse({ + status: 'success', + messages: [ + { + messageId: 'bad-encryption', + sender: recipientA, + body: JSON.stringify({ encryptedMessage: 'AQID' }) + } + ] + }) + ) + + const messages = await client.listMessagesLite({ + messageBox: 'inbox', + host: 'https://message-box.example/api' + }) + expect(messages[0].body).toBe('[Error: Failed to decrypt or parse message]') + }) + + it('unwraps a string envelope while using the configured default host', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + status: 'success', + messages: [ + { + messageId: 'wrapped', + sender: recipientA, + body: JSON.stringify({ message: JSON.stringify({ plain: true }) }) + }, + { + messageId: 'wrapped-object', + sender: recipientB, + body: JSON.stringify({ message: { plain: 'object' } }) + } + ] + }) + ) + + await expect(client.listMessagesLite({ messageBox: 'inbox' })).resolves.toEqual([ + expect.objectContaining({ messageId: 'wrapped', body: { plain: true } }), + expect.objectContaining({ messageId: 'wrapped-object', body: { plain: 'object' } }) + ]) + await expect(client.listMessagesLite({ messageBox: ' ' })).rejects.toThrow( + 'MessageBox cannot be empty' + ) + }) + }) + + describe('permissions, notifications, and devices', () => { + it('sets a permission and surfaces both HTTP and application failures', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ status: 'success' })) + .mockResolvedValueOnce( + jsonResponse( + { description: 'forbidden' }, + { ok: false, status: 403, statusText: 'Forbidden' } + ) + ) + .mockResolvedValueOnce(jsonResponse({ status: 'error', description: 'invalid fee' })) + + await expect( + client.setMessageBoxPermission( + { messageBox: 'notifications', sender: recipientA, recipientFee: 0 }, + 'https://message-box.example/api' + ) + ).resolves.toBeUndefined() + await expect( + client.setMessageBoxPermission( + { messageBox: 'notifications', recipientFee: 0 }, + 'https://message-box.example/api' + ) + ).rejects.toThrow('forbidden') + await expect( + client.setMessageBoxPermission( + { messageBox: 'notifications', recipientFee: 0 }, + 'https://message-box.example/api' + ) + ).rejects.toThrow('invalid fee') + }) + + it('surfaces get-permission and quote response failures', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse({}, { ok: false, status: 500, statusText: 'Unavailable' }) + ) + .mockResolvedValueOnce(jsonResponse({ status: 'error', description: 'not available' })) + .mockResolvedValueOnce( + jsonResponse({}, { ok: false, status: 503, statusText: 'Unavailable' }) + ) + .mockResolvedValueOnce(jsonResponse({ status: 'error', description: 'bad quote' })) + .mockResolvedValueOnce( + jsonResponse( + { status: 'success', quote: { recipientFee: 0, deliveryFee: 0 } }, + { headers: new Headers() } + ) + ) + + const permissionParams = { + recipient: recipientA, + messageBox: 'notifications' + } + await expect( + client.getMessageBoxPermission(permissionParams, 'https://message-box.example/api') + ).rejects.toThrow('HTTP 500') + await expect( + client.getMessageBoxPermission(permissionParams, 'https://message-box.example/api') + ).rejects.toThrow('not available') + + const quoteParams = { recipient: recipientA, messageBox: 'notifications' } + await expect( + client.getMessageBoxQuote(quoteParams, 'https://message-box.example/api') + ).rejects.toThrow('HTTP 503') + await expect( + client.getMessageBoxQuote(quoteParams, 'https://message-box.example/api') + ).rejects.toThrow('bad quote') + await expect( + client.getMessageBoxQuote(quoteParams, 'https://message-box.example/api') + ).rejects.toThrow('did not provide their identity key') + }) + + it('rejects malformed list-permission payloads and maps paid permissions', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse( + { description: 'permission listing denied' }, + { ok: false, status: 403, statusText: 'Forbidden' } + ) + ) + .mockResolvedValueOnce( + jsonResponse({ status: 'error', description: 'permission query failed' }) + ) + .mockResolvedValueOnce(jsonResponse({ status: 'success', permissions: {} })) + .mockResolvedValueOnce(jsonResponse({ status: 'success', permissions: [null] })) + .mockResolvedValueOnce( + jsonResponse({ + status: 'success', + permissions: [ + { + sender: recipientA, + messageBox: 'notifications', + recipientFee: 2, + createdAt: 'created', + updatedAt: 'updated' + } + ] + }) + ) + + await expect(client.listMessageBoxPermissions()).rejects.toThrow('permission listing denied') + await expect(client.listMessageBoxPermissions()).rejects.toThrow('permission query failed') + await expect(client.listMessageBoxPermissions()).rejects.toThrow( + 'invalid permissions payload' + ) + await expect(client.listMessageBoxPermissions()).rejects.toThrow('invalid permission record') + await expect(client.listMessageBoxPermissions()).resolves.toEqual([ + expect.objectContaining({ recipientFee: 2, status: 'payment_required' }) + ]) + }) + + it('forwards notification permission convenience methods', async () => { + const set = jest.spyOn(client, 'setMessageBoxPermission').mockResolvedValue() + const get = jest.spyOn(client, 'getMessageBoxPermission').mockResolvedValue(null) + const list = jest.spyOn(client, 'listMessageBoxPermissions').mockResolvedValue([]) + jest.spyOn(client, 'getIdentityKey').mockResolvedValue(identityKey) + + await client.allowNotificationsFromPeer(recipientA, 2, 'https://override.example') + await client.allowNotificationsFromPeer(recipientA) + await client.denyNotificationsFromPeer(recipientB, 'https://override.example') + await client.checkPeerNotificationStatus(recipientA, 'https://override.example') + await client.listPeerNotifications('https://override.example') + + expect(set).toHaveBeenNthCalledWith( + 1, + { messageBox: 'notifications', sender: recipientA, recipientFee: 2 }, + 'https://override.example' + ) + expect(set).toHaveBeenNthCalledWith( + 2, + { messageBox: 'notifications', sender: recipientA, recipientFee: 0 }, + undefined + ) + expect(set).toHaveBeenNthCalledWith( + 3, + { messageBox: 'notifications', sender: recipientB, recipientFee: -1 }, + 'https://override.example' + ) + expect(get).toHaveBeenCalledWith( + { recipient: identityKey, messageBox: 'notifications', sender: recipientA }, + 'https://override.example' + ) + expect(list).toHaveBeenCalledWith({ messageBox: 'notifications' }, 'https://override.example') + }) + + it('reports partial and failed encrypted notification batches', async () => { + const send = jest + .spyOn(client, 'sendMessage') + .mockResolvedValueOnce({ status: 'success', messageId: 'one' }) + .mockRejectedValueOnce(new Error('blocked')) + + await expect( + client.sendNotification([recipientA, recipientB], 'hello') + ).resolves.toMatchObject({ + status: 'partial', + sent: [{ recipient: recipientA, messageId: 'one' }], + failed: [{ recipient: recipientB, error: 'blocked' }] + }) + + send.mockReset() + send.mockRejectedValue('unknown failure') + await expect( + client.sendNotification([recipientA, recipientB], 'hello') + ).resolves.toMatchObject({ + status: 'error', + sent: [], + failed: [ + { recipient: recipientA, error: 'Unknown error' }, + { recipient: recipientB, error: 'Unknown error' } + ] + }) + }) + + it('forwards a single notification through the standard message sender', async () => { + const send = jest.spyOn(client, 'sendMessage').mockResolvedValue({ + status: 'success', + messageId: 'notification' + }) + + await client.sendNotification(recipientA, { title: 'Hello' }, 'https://override.example') + expect(send).toHaveBeenCalledWith( + { + recipient: recipientA, + messageBox: 'notifications', + body: { title: 'Hello' }, + checkPermissions: true + }, + 'https://override.example' + ) + }) + + it('acknowledges notifications across every recipient-payment outcome', async () => { + jest.spyOn(client, 'acknowledgeMessage').mockResolvedValue('success') + const payment = { + tx: [1, 2, 3], + outputs: [ + { + outputIndex: 0, + protocol: 'wallet payment', + paymentRemittance: { + derivationPrefix: 'prefix', + derivationSuffix: 'suffix', + senderIdentityKey: identityKey + } + } + ] + } + const message = (body: unknown) => ({ + messageId: 'message', + sender: recipientA, + body, + created_at: '', + updated_at: '' + }) + + await expect(client.acknowledgeNotification(message('plain'))).resolves.toBe(false) + await expect( + client.acknowledgeNotification( + message(JSON.stringify({ message: 'hello', payment: { ...payment, outputs: [] } })) + ) + ).resolves.toBe(false) + await expect( + client.acknowledgeNotification(message(JSON.stringify({ message: 'hello', payment }))) + ).resolves.toBe(true) + + wallet.internalizeAction.mockResolvedValueOnce({ accepted: false }) + await expect( + client.acknowledgeNotification(message(JSON.stringify({ message: 'hello', payment }))) + ).resolves.toBe(false) + + wallet.internalizeAction.mockRejectedValueOnce(new Error('wallet unavailable')) + await expect( + client.acknowledgeNotification(message(JSON.stringify({ message: 'hello', payment }))) + ).resolves.toBe(false) + }) + + it.each([ + [{ fcmToken: '' }, 'fcmToken is required'], + [{ fcmToken: 'x'.repeat(501) }, 'must not exceed 500'], + [{ fcmToken: 'token', deviceId: 'x'.repeat(256) }, 'deviceId must not exceed 255'], + [{ fcmToken: 'token', platform: 'desktop' }, 'platform must be one of'] + ])('rejects invalid device registration %#', async (params, error) => { + await expect(client.registerDevice(params as never)).rejects.toThrow(error) + }) + + it('registers and lists bounded device pages', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse({ status: 'success', message: 'registered', deviceId: 'device-1' }) + ) + .mockResolvedValueOnce( + jsonResponse({ + status: 'success', + devices: [{ fcmToken: 'token', platform: 'web', deviceId: 'device-1' }] + }) + ) + + await expect( + client.registerDevice( + { fcmToken: ' token ', platform: 'web', deviceId: ' device-1 ' }, + 'https://message-box.example/api' + ) + ).resolves.toEqual({ + status: 'success', + message: 'registered', + deviceId: 'device-1' + }) + await expect( + client.listRegisteredDevices('https://message-box.example/api', { + limit: 10, + offset: 20 + }) + ).resolves.toHaveLength(1) + + expect(fetchMock.mock.calls[1][0]).toContain('/devices?limit=10&offset=20') + }) + + it('surfaces HTTP and application errors from device routes', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse( + { description: 'registration denied' }, + { ok: false, status: 403, statusText: 'Forbidden' } + ) + ) + .mockResolvedValueOnce(jsonResponse({ status: 'error', description: 'bad token' })) + .mockResolvedValueOnce( + jsonResponse( + { description: 'listing denied' }, + { ok: false, status: 403, statusText: 'Forbidden' } + ) + ) + .mockResolvedValueOnce( + jsonResponse({ status: 'error', description: 'device query failed', devices: [] }) + ) + + await expect(client.registerDevice({ fcmToken: 'token' })).rejects.toThrow( + 'registration denied' + ) + await expect(client.registerDevice({ fcmToken: 'token' })).rejects.toThrow('bad token') + await expect(client.listRegisteredDevices()).rejects.toThrow('listing denied') + await expect(client.listRegisteredDevices()).rejects.toThrow('device query failed') + }) + }) + + describe('transport failure boundaries', () => { + it('wraps invalid initialization hosts and exercises the testnet default', async () => { + const testnetClient = new MessageBoxClient({ + networkPreset: 'testnet', + walletClient: wallet + }) + await expect(testnetClient.init('not a URL')).rejects.toThrow('Cannot initialize') + }) + + it('surfaces consumed, HTTP-error, and application-error send responses', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ status: 'success' }, { bodyUsed: true })) + .mockResolvedValueOnce( + jsonResponse({}, { ok: false, status: 503, statusText: 'Unavailable' }) + ) + .mockResolvedValueOnce( + jsonResponse({ status: 'error', description: 'server rejected message' }) + ) + + const send = () => + client.sendMessage( + { + recipient: recipientA, + messageBox: 'inbox', + body: 'hello', + skipEncryption: true + }, + 'https://message-box.example/api' + ) + + await expect(send()).rejects.toThrow('Response body has already been used') + await expect(send()).rejects.toThrow('HTTP 503') + await expect(send()).rejects.toThrow('server rejected message') + }) + + it('wraps a non-Error permission lookup rejection', async () => { + jest + .spyOn(client, 'getMessageBoxQuote') + .mockRejectedValueOnce('lookup failed') + .mockRejectedValueOnce(new Error('lookup failed')) + await expect( + client.sendMessage( + { + recipient: recipientA, + messageBox: 'inbox', + body: 'hello', + checkPermissions: true + }, + 'https://message-box.example/api' + ) + ).rejects.toThrow('Permission check failed: Unknown error') + await expect( + client.sendMessage( + { + recipient: recipientA, + messageBox: 'inbox', + body: 'hello', + checkPermissions: true + }, + 'https://message-box.example/api' + ) + ).rejects.toThrow('Permission check failed: lookup failed') + }) + + it('handles successful and failed WebSocket acknowledgements', async () => { + const listeners = new Map void>() + const socket = { + connected: true, + on: jest.fn((event: string, handler: (response?: unknown) => void) => { + listeners.set(event, handler) + }), + off: jest.fn(), + emit: jest.fn(), + disconnect: jest.fn() + } + ;(client as any).socket = socket + ;(client as any).myIdentityKey = identityKey + jest.spyOn(client, 'joinRoom').mockResolvedValue() + const fallback = jest.spyOn(client, 'sendMessage').mockResolvedValue({ + status: 'success', + messageId: 'fallback' + }) + const waitForAckHandler = async ( + previous?: (response?: unknown) => void + ): Promise<(response?: unknown) => void> => { + for (let attempt = 0; attempt < 20; attempt++) { + const handler = listeners.get(`sendMessageAck-${recipientA}-inbox`) + if (handler != null && handler !== previous) return handler + await new Promise(resolve => setImmediate(resolve)) + } + throw new Error('WebSocket acknowledgement handler was not registered') + } + + const successfulSend = client.sendLiveMessage( + { + recipient: recipientA, + messageBox: 'inbox', + body: { hello: 'world' } + }, + 'https://override.example' + ) + const successfulAck = await waitForAckHandler() + successfulAck({ + status: 'success', + messageId: 'live' + }) + await expect(successfulSend).resolves.toMatchObject({ status: 'success', messageId: 'live' }) + + const fallbackSend = client.sendLiveMessage({ + recipient: recipientA, + messageBox: 'inbox', + body: 'hello', + skipEncryption: true + }) + const failedAck = await waitForAckHandler(successfulAck) + failedAck({ status: 'error' }) + await expect(fallbackSend).resolves.toMatchObject({ messageId: 'fallback' }) + expect(fallback).toHaveBeenCalled() + }) + + it('rejects live delivery when HMAC generation fails', async () => { + ;(client as any).socket = { + connected: true, + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + disconnect: jest.fn() + } + jest.spyOn(client, 'joinRoom').mockResolvedValue() + wallet.createHmac.mockRejectedValueOnce(new Error('wallet failed')) + + await expect( + client.sendLiveMessage({ + recipient: recipientA, + messageBox: 'inbox', + body: 'hello' + }) + ).rejects.toThrow('Failed to generate message identifier') + }) + + it('decrypts live messages and protects callbacks from unstringifiable payloads', async () => { + const listeners = new Map void>() + ;(client as any).socket = { + connected: true, + on: jest.fn((event: string, handler: (message: any) => void) => { + listeners.set(event, handler) + }), + off: jest.fn(), + emit: jest.fn(), + disconnect: jest.fn() + } + ;(client as any).myIdentityKey = identityKey + jest.spyOn(client, 'joinRoom').mockResolvedValue() + const onMessage = jest.fn() + await client.listenForLiveMessages({ messageBox: 'inbox', onMessage }) + + const listener = listeners.get(`sendMessage-${identityKey}-inbox`) + listener?.({ + messageId: 'encrypted', + sender: recipientA, + body: JSON.stringify({ encryptedMessage: 'AQID' }) + }) + const circular: Record = {} + circular.self = circular + listener?.({ + messageId: 'circular', + sender: recipientA, + body: circular + }) + listener?.({ + messageId: 'plain', + sender: recipientA, + body: 'plain text' + }) + await new Promise(resolve => setImmediate(resolve)) + + expect(onMessage).toHaveBeenCalledTimes(3) + const delivered = onMessage.mock.calls.map(([message]) => message) + expect(delivered.find(message => message.messageId === 'encrypted')?.body).toBe( + '{"secret":true}' + ) + expect(delivered.find(message => message.messageId === 'circular')?.body).toBe( + '[Error: Unstringifiable message]' + ) + expect(delivered.find(message => message.messageId === 'plain')?.body).toBe('plain text') + }) + }) +}) diff --git a/packages/messaging/message-box-client/src/__tests/PeerPayClientUnit.test.ts b/packages/messaging/message-box-client/src/__tests/PeerPayClientUnit.test.ts index d7fd9f9e5..976af0435 100644 --- a/packages/messaging/message-box-client/src/__tests/PeerPayClientUnit.test.ts +++ b/packages/messaging/message-box-client/src/__tests/PeerPayClientUnit.test.ts @@ -154,6 +154,29 @@ describe('PeerPayClient Unit Tests', () => { undefined ) }) + + it('falls back to HTTP when live delivery fails', async () => { + jest.spyOn(peerPayClient, 'createPaymentToken').mockResolvedValue({ + customInstructions: { derivationPrefix: 'prefix', derivationSuffix: 'suffix' }, + transaction: [1, 2, 3], + amount: 2 + }) + jest.spyOn(peerPayClient, 'sendLiveMessage').mockRejectedValue(new Error('socket failed')) + const send = jest.spyOn(peerPayClient, 'sendMessage').mockResolvedValue({ + status: 'success', + messageId: 'http-message' + }) + + await peerPayClient.sendLivePayment( + { recipient: 'recipientKey', amount: 2 }, + 'https://override.example' + ) + + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ recipient: 'recipientKey', messageBox: 'payment_inbox' }), + 'https://override.example' + ) + }) }) // Test: acceptPayment @@ -207,6 +230,73 @@ describe('PeerPayClient Unit Tests', () => { messageIds: ['123'] }) }) + + it('acknowledges a payment that is too small to refund', async () => { + const acknowledge = jest.spyOn(peerPayClient, 'acknowledgeMessage').mockResolvedValue('ok') + ;(peerPayClient as any).authFetch = undefined + await peerPayClient.rejectPayment({ + messageId: 'small', + sender: 'senderKey', + token: { + customInstructions: { derivationPrefix: 'prefix', derivationSuffix: 'suffix' }, + transaction: [1, 2, 3], + amount: 1500 + } + }) + expect(acknowledge).toHaveBeenCalledWith({ messageIds: ['small'] }) + }) + + it('tolerates a 401 while acknowledging a payment that is too small to refund', async () => { + jest + .spyOn(peerPayClient, 'acknowledgeMessage') + .mockRejectedValue(new Error('HTTP 401 unauthorized')) + + await expect( + peerPayClient.rejectPayment({ + messageId: 'small', + sender: 'senderKey', + token: { + customInstructions: { derivationPrefix: 'prefix', derivationSuffix: 'suffix' }, + transaction: [1, 2, 3], + amount: 1500 + } + }) + ).resolves.toBeUndefined() + }) + + it('rethrows a non-authentication acknowledgement failure for a small payment', async () => { + jest.spyOn(peerPayClient, 'acknowledgeMessage').mockRejectedValue(new Error('network failed')) + + await expect( + peerPayClient.rejectPayment({ + messageId: 'small', + sender: 'senderKey', + token: { + customInstructions: { derivationPrefix: 'prefix', derivationSuffix: 'suffix' }, + transaction: [1, 2, 3], + amount: 1500 + } + }) + ).rejects.toThrow('network failed') + }) + + it('does not fail a completed refund when the final acknowledgement fails', async () => { + jest.spyOn(peerPayClient, 'acceptPayment').mockResolvedValue(undefined) + jest.spyOn(peerPayClient, 'sendPayment').mockResolvedValue(undefined) + jest.spyOn(peerPayClient, 'acknowledgeMessage').mockRejectedValue(new Error('offline')) + + await expect( + peerPayClient.rejectPayment({ + messageId: 'large', + sender: 'senderKey', + token: { + customInstructions: { derivationPrefix: 'prefix', derivationSuffix: 'suffix' }, + transaction: [1, 2, 3], + amount: 3000 + } + }) + ).resolves.toBeUndefined() + }) }) // Test: listIncomingPayments @@ -234,6 +324,13 @@ describe('PeerPayClient Unit Tests', () => { transaction: toArray('mockedTransaction2', 'utf8'), amount: 9 }) + }, + { + messageId: 'invalid', + sender: 'sender3', + created_at: '2025-03-05T12:20:00Z', + updated_at: '2025-03-05T12:25:00Z', + body: '{' } ]) @@ -475,6 +572,28 @@ describe('PeerPayClient Unit Tests', () => { expect(requests[0].requestId).toBe('req-1') }) + it('discards a cancellation whose HMAC proof is invalid', async () => { + mockWalletClient.verifyHmac.mockRejectedValueOnce(new Error('invalid proof')) + jest.spyOn(peerPayClient, 'listMessages').mockResolvedValue([ + { + messageId: 'invalid-cancel', + sender: 'sender1', + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + body: JSON.stringify({ + requestId: 'req-1', + senderIdentityKey: 'sender1', + cancelled: true, + requestProof: 'abcd1234' + }) + } + ]) + const acknowledge = jest.spyOn(peerPayClient, 'acknowledgeMessage').mockResolvedValue('ok') + + await expect(peerPayClient.listIncomingPaymentRequests()).resolves.toEqual([]) + expect(acknowledge).toHaveBeenCalledWith({ messageIds: ['invalid-cancel'] }) + }) + it('filters out requests below minAmount and above maxAmount, acknowledges them', async () => { jest.spyOn(peerPayClient, 'listMessages').mockResolvedValue([ { @@ -837,6 +956,20 @@ describe('PeerPayClient Unit Tests', () => { }) ).rejects.toThrow() }) + + it('translates a permission-denied response into a whitelist error', async () => { + jest.spyOn(peerPayClient, 'getIdentityKey').mockResolvedValue('myIdentityKey') + jest.spyOn(peerPayClient, 'sendMessage').mockRejectedValue(new Error('HTTP 403 Forbidden')) + + await expect( + peerPayClient.requestPayment({ + recipient: 'recipientKey', + amount: 1000, + description: 'Please pay me', + expiresAt: Date.now() + 60_000 + }) + ).rejects.toThrow("not on the recipient's whitelist") + }) }) // Test: cancelPaymentRequest @@ -871,4 +1004,12 @@ describe('PeerPayClient Unit Tests', () => { }) }) }) + + it('lazily creates and then reuses its dedicated AuthFetch instance', () => { + const defaultHostClient = new PeerPayClient({ walletClient: mockWalletClient }) + const first = (peerPayClient as any).authFetchInstance + const second = (peerPayClient as any).authFetchInstance + expect(first).toBe(second) + expect(defaultHostClient).toBeInstanceOf(PeerPayClient) + }) }) diff --git a/packages/messaging/message-box-client/src/__tests/PeerTokenClientUnit.test.ts b/packages/messaging/message-box-client/src/__tests/PeerTokenClientUnit.test.ts index 058e203c2..4fd5d1f5b 100644 --- a/packages/messaging/message-box-client/src/__tests/PeerTokenClientUnit.test.ts +++ b/packages/messaging/message-box-client/src/__tests/PeerTokenClientUnit.test.ts @@ -48,12 +48,14 @@ describe('PeerTokenClient Unit Tests', () => { let client: PeerTokenClient let adapter: TokenSettlementAdapter let recipient: string + let wallet: jest.Mocked beforeEach(() => { jest.clearAllMocks() adapter = makeStubAdapter() recipient = PrivateKey.fromRandom().toPublicKey().toString() - client = new PeerTokenClient({ walletClient: createMockWalletClient(), adapters: [adapter] }) + wallet = createMockWalletClient() + client = new PeerTokenClient({ walletClient: wallet, adapters: [adapter] }) }) describe('createTokenToken', () => { @@ -197,5 +199,314 @@ describe('PeerTokenClient Unit Tests', () => { expect(wallet.verifyHmac).not.toHaveBeenCalled() } ) + + it('decodes a valid hexadecimal proof and verifies it with the wallet', async () => { + wallet.getPublicKey.mockResolvedValue({ publicKey: recipient }) + + await expect( + client.verifyTokenRequestProof({ + requestId: 'request-1', + sender: recipient, + requestProof: '0102ff' + }) + ).resolves.toBe(true) + + expect(wallet.verifyHmac).toHaveBeenCalledWith( + expect.objectContaining({ hmac: [1, 2, 255], counterparty: recipient }), + undefined + ) + }) + }) + + describe('live token transport', () => { + it('sends a token through the live transport using an explicit host', async () => { + const live = jest.spyOn(client, 'sendLiveMessage').mockResolvedValue({ + status: 'success', + messageId: 'live-1' + }) + + await expect( + client.sendLiveToken( + { recipient, protocol: 'stas', source: SOURCE, amount: '1000' }, + 'https://override.example' + ) + ).resolves.toMatchObject({ protocol: 'stas', amount: '1000' }) + + expect(live).toHaveBeenCalledWith( + expect.objectContaining({ recipient, messageBox: STANDARD_TOKEN_MESSAGEBOX }), + 'https://override.example' + ) + }) + + it('falls back to HTTP when live delivery fails', async () => { + jest.spyOn(client, 'sendLiveMessage').mockRejectedValue(new Error('socket unavailable')) + const send = jest.spyOn(client, 'sendMessage').mockResolvedValue({ + status: 'success', + messageId: 'http-1' + }) + + await client.sendLiveToken({ recipient, protocol: 'stas', source: SOURCE, amount: '1000' }) + + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ recipient, messageBox: STANDARD_TOKEN_MESSAGEBOX }), + 'https://message-box-us-1.bsvb.tech' + ) + }) + + it('parses valid live tokens and ignores malformed payloads', async () => { + const listen = jest.spyOn(client, 'listenForLiveMessages').mockResolvedValue() + const onToken = jest.fn() + await client.listenForLiveTokens({ onToken, overrideHost: 'https://override.example' }) + + const { onMessage } = listen.mock.calls[0][0] + onMessage({ + messageId: 'bad', + sender: recipient, + body: '{', + created_at: '', + updated_at: '' + }) + onMessage({ + messageId: 'good', + sender: recipient, + body: JSON.stringify(ARTIFACT), + created_at: '', + updated_at: '' + }) + + expect(onToken).toHaveBeenCalledTimes(1) + expect(onToken).toHaveBeenCalledWith( + expect.objectContaining({ messageId: 'good', sender: recipient }) + ) + }) + }) + + describe('token request lifecycle', () => { + const request = { + messageId: 'request-message', + sender: 'requester-key', + requestId: 'request-1', + protocol: 'stas', + assetId: 'TEST', + amount: '1000', + description: 'Send token', + expiresAt: Date.now() + 60_000 + } + + it('parses valid incoming tokens and filters malformed tokens', async () => { + jest.spyOn(client, 'listMessagesLite').mockResolvedValue([ + { + messageId: 'valid', + sender: recipient, + body: JSON.stringify(ARTIFACT), + created_at: '', + updated_at: '' + }, + { + messageId: 'invalid', + sender: recipient, + body: '{', + created_at: '', + updated_at: '' + } + ]) + + await expect(client.listIncomingTokens('https://override.example')).resolves.toEqual([ + expect.objectContaining({ messageId: 'valid', sender: recipient }) + ]) + }) + + it('creates and sends an authenticated token request', async () => { + jest.spyOn(client, 'getIdentityKey').mockResolvedValue('sender-key') + const send = jest.spyOn(client, 'sendMessage').mockResolvedValue({ + status: 'success', + messageId: 'request-message' + }) + + const result = await client.requestToken( + { + recipient, + protocol: 'stas', + assetId: 'TEST', + amount: '1000', + description: 'Send token', + expiresAt: Date.now() + 60_000 + }, + 'https://override.example' + ) + + expect(result.requestProof).toBe('010203') + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ + recipient, + messageBox: 'token_requests', + body: expect.stringContaining('"requestProof":"010203"') + }), + 'https://override.example' + ) + }) + + it('uses the configured token host when a request has no override', async () => { + jest.spyOn(client, 'getIdentityKey').mockResolvedValue('sender-key') + const send = jest.spyOn(client, 'sendMessage').mockResolvedValue({ + status: 'success', + messageId: 'request-message' + }) + + await client.requestToken({ + recipient, + protocol: 'stas', + assetId: 'TEST', + amount: '1000', + description: 'Send token', + expiresAt: Date.now() + 60_000 + }) + + expect(send).toHaveBeenCalledWith(expect.any(Object), 'https://message-box-us-1.bsvb.tech') + }) + + it('converts valid live requests while ignoring cancelled and malformed payloads', async () => { + const listen = jest.spyOn(client, 'listenForLiveMessages').mockResolvedValue() + const onRequest = jest.fn() + await client.listenForLiveTokenRequests({ onRequest }) + + const { onMessage } = listen.mock.calls[0][0] + const baseMessage = { + sender: request.sender, + created_at: '', + updated_at: '' + } + onMessage({ ...baseMessage, messageId: 'bad', body: '{' }) + onMessage({ + ...baseMessage, + messageId: 'cancelled', + body: JSON.stringify({ requestId: request.requestId, cancelled: true }) + }) + onMessage({ ...baseMessage, messageId: request.messageId, body: JSON.stringify(request) }) + + expect(onRequest).toHaveBeenCalledTimes(1) + expect(onRequest).toHaveBeenCalledWith(expect.objectContaining(request)) + }) + + it('fulfills a request, responds, and acknowledges the request message', async () => { + const sendToken = jest.spyOn(client, 'sendToken').mockResolvedValue(ARTIFACT) + const sendMessage = jest.spyOn(client, 'sendMessage').mockResolvedValue({ + status: 'success', + messageId: 'response-message' + }) + const acknowledge = jest.spyOn(client, 'acknowledgeMessage').mockResolvedValue('ok') + + await client.fulfillTokenRequest( + { request, source: SOURCE, note: 'settled' }, + 'https://override.example' + ) + + expect(sendToken).toHaveBeenCalledWith( + { + recipient: request.sender, + protocol: request.protocol, + source: SOURCE, + amount: request.amount + }, + 'https://override.example' + ) + expect(JSON.parse(String(sendMessage.mock.calls[0][0].body))).toMatchObject({ + requestId: request.requestId, + status: 'sent', + note: 'settled' + }) + expect(acknowledge).toHaveBeenCalledWith({ + messageIds: [request.messageId], + host: 'https://override.example' + }) + }) + + it('uses the configured token host throughout fulfillment without an override', async () => { + const sendToken = jest.spyOn(client, 'sendToken').mockResolvedValue(ARTIFACT) + const sendMessage = jest.spyOn(client, 'sendMessage').mockResolvedValue({ + status: 'success', + messageId: 'response-message' + }) + const acknowledge = jest.spyOn(client, 'acknowledgeMessage').mockResolvedValue('ok') + + await client.fulfillTokenRequest({ request, source: SOURCE }) + + expect(sendToken).toHaveBeenCalledWith( + expect.any(Object), + 'https://message-box-us-1.bsvb.tech' + ) + expect(sendMessage).toHaveBeenCalledWith( + expect.any(Object), + 'https://message-box-us-1.bsvb.tech' + ) + expect(acknowledge).toHaveBeenCalledWith({ + messageIds: [request.messageId], + host: 'https://message-box-us-1.bsvb.tech' + }) + }) + + it('declines and acknowledges a request', async () => { + const send = jest.spyOn(client, 'sendMessage').mockResolvedValue({ + status: 'success', + messageId: 'response-message' + }) + const acknowledge = jest.spyOn(client, 'acknowledgeMessage').mockResolvedValue('ok') + + await client.declineTokenRequest({ request, note: 'not available' }) + + expect(JSON.parse(String(send.mock.calls[0][0].body))).toEqual({ + requestId: request.requestId, + status: 'declined', + note: 'not available' + }) + expect(acknowledge).toHaveBeenCalledWith({ + messageIds: [request.messageId], + host: 'https://message-box-us-1.bsvb.tech' + }) + }) + + it('sends a cancellation bound to the current identity key', async () => { + jest.spyOn(client, 'getIdentityKey').mockResolvedValue('sender-key') + const send = jest.spyOn(client, 'sendMessage').mockResolvedValue({ + status: 'success', + messageId: 'cancel-message' + }) + + await client.cancelTokenRequest({ + recipient, + requestId: request.requestId, + requestProof: '010203' + }) + + expect(JSON.parse(String(send.mock.calls[0][0].body))).toEqual({ + requestId: request.requestId, + senderIdentityKey: 'sender-key', + requestProof: '010203', + cancelled: true + }) + }) + + it('lists valid request responses and drops malformed ones', async () => { + jest.spyOn(client, 'listMessagesLite').mockResolvedValue([ + { + messageId: 'valid', + sender: recipient, + body: { requestId: request.requestId, status: 'declined' }, + created_at: '', + updated_at: '' + }, + { + messageId: 'invalid', + sender: recipient, + body: '{', + created_at: '', + updated_at: '' + } + ]) + + await expect(client.listTokenRequestResponses()).resolves.toEqual([ + { requestId: request.requestId, status: 'declined' } + ]) + }) }) }) diff --git a/packages/messaging/message-box-client/src/__tests/host.test.ts b/packages/messaging/message-box-client/src/__tests/host.test.ts index 3dc865900..717403b29 100644 --- a/packages/messaging/message-box-client/src/__tests/host.test.ts +++ b/packages/messaging/message-box-client/src/__tests/host.test.ts @@ -24,6 +24,10 @@ describe('Message Box host validation', () => { expect(() => normalizeMessageBoxHost(input)).toThrow() }) + it('rejects a non-string host at the runtime boundary', () => { + expect(() => normalizeMessageBoxHost(42 as unknown as string)).toThrow(TypeError) + }) + it('preserves an operator-controlled route prefix when building endpoints', () => { expect(messageBoxEndpoint('https://message.example.org/api/', '/sendMessage')).toBe( 'https://message.example.org/api/sendMessage' @@ -33,20 +37,42 @@ describe('Message Box host validation', () => { it.each([ 'http://message.example.org', 'https://localhost:8080', + 'https://service.local', + 'https://service.lan', + 'https://service.home', + 'https://service.test', + 'https://service.invalid', + 'https://0.0.0.0', 'https://127.0.0.1', 'https://10.0.0.4', + 'https://100.64.0.1', + 'https://169.254.1.1', 'https://172.16.0.1', 'https://192.168.1.1', + 'https://198.18.0.1', + 'https://198.19.0.1', 'https://[::1]', + 'https://[::]', + 'https://[fc00::1]', + 'https://[fd00::1]', + 'https://[fe80::1]', 'https://service.internal', 'https://message.example.com' ])('rejects unsafe overlay destination %s', input => { expect(normalizeOverlayMessageBoxHost(input)).toBeUndefined() }) + it('rejects malformed overlay advertisements without throwing', () => { + expect(normalizeOverlayMessageBoxHost('not a URL')).toBeUndefined() + }) + it('accepts and canonicalizes a public HTTPS overlay destination', () => { expect(normalizeOverlayMessageBoxHost('https://message.example.org/api/')).toBe( 'https://message.example.org/api' ) }) + + it('rejects an empty endpoint path', () => { + expect(() => messageBoxEndpoint('https://message.example.org', '///')).toThrow(TypeError) + }) }) diff --git a/packages/messaging/message-box-client/src/host.ts b/packages/messaging/message-box-client/src/host.ts index 506a71cd4..0b0c4d1bd 100644 --- a/packages/messaging/message-box-client/src/host.ts +++ b/packages/messaging/message-box-client/src/host.ts @@ -1,7 +1,7 @@ const MAX_HOST_LENGTH = 2048 function isPrivateIpv4(hostname: string): boolean { - const octets = hostname.split('.').map(part => Number(part)) + const octets = hostname.split('.').map(Number) if ( octets.length !== 4 || octets.some(octet => !Number.isInteger(octet) || octet < 0 || octet > 255) @@ -82,7 +82,8 @@ export function normalizeMessageBoxHost(host: string): string { throw new TypeError('Message Box host must not contain a query or fragment') } - const pathname = url.pathname.replace(/\/+$/, '') + let pathname = url.pathname + while (pathname.endsWith('/')) pathname = pathname.slice(0, -1) return pathname === '' ? url.origin : `${url.origin}${pathname}` }