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.
The service is distributed as a container; it is not a public npm package.
See Service Resource Profiles for all runtime ceilings, BRC-105 pricing, capacity evidence, and HPA prerequisites.
Message Box encrypts message content end to end between sender and recipient, so the host operator cannot read what you send. Private keys never leave your wallet, and the server returns messages only after it authenticates the recipient's identity key.
To deliver messages, the operator can still see some metadata: when messages are sent and received, message size, and which identity keys (pseudonyms) exchange messages with each other, and the IP addresses that connect. Identity keys are not names, but repeated use can link activity over time.
This balance keeps delivery simple and reliable while keeping content private. If unlinkability matters to you, use separate identity keys for each context or run your own Message Box server.
- Node.js 24
- Express 5
- MySQL 8 through Knex
- BRC-103 authentication over HTTP and WebSocket transports
@bsv/wallet-toolboxfor the service wallet- optional Firebase Admin push delivery
- optional OpenTelemetry OTLP export
Clients normally use
@bsv/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.
Recipient permission fees are durable security policy. A write is reported as
successful only after the database confirms it. Stored recipient fees must be
safe integers from -1 (blocked) through 2,147,483,647, while legacy server
delivery fees must be safe integers from 0 through the same maximum. A
malformed persisted value fails quote and send operations closed instead of
being interpreted as free delivery.
For a batch, the server fee applies per recipient and is verified as one
aggregate remittance before any message is stored.
Batch recipients are canonicalized and must be unique, so equivalent key
encodings cannot create ambiguous permission, payment, or message-ID rows.
Message-box names and message IDs are exact, control-free, UTF-8-byte-bounded
identifiers on every HTTP and WebSocket path; the server never trims an
ambiguous identifier into a different routing or authorization key.
HTTP authentication uses durable auth_sessions and auth_message_nonces tables
shared by all replicas. The September 24 service migration adds the replay table
required by KnexSessionManager; older databases missing it fail initial HTTP
authentication even when health and WebSocket checks pass. See
deployment and recovery guidance.
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.
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, includingOrigin: nullallowlist— exact origins fromMESSAGE_BOX_CORS_ALLOWED_ORIGINSdisabled— 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.
Application logs carry fixed operation/outcome and bounded aggregate metadata. They intentionally omit message bodies and IDs, permission subjects, FCM tokens and device identifiers, payment/authentication material, raw database queries, provider errors, and caught exception objects. Use request IDs and trace correlation for investigation rather than enabling payload logging.
| 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 testnet or ttn/teratestnet for TerraTestNet |
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_WEBSOCKET_MAX_CONNECTIONS |
1,000 active connections/process |
MESSAGE_BOX_WEBSOCKET_MAX_CONNECTIONS_PER_IDENTITY |
25 active connections/identity |
MESSAGE_BOX_WEBSOCKET_MAX_ROOMS_PER_CONNECTION |
128 canonical rooms/connection |
MESSAGE_BOX_WEBSOCKET_CONTROL_RATE_LIMIT |
600 join/leave events/minute/socket |
MESSAGE_BOX_WEBSOCKET_MAX_CONCURRENT_SENDS |
4 |
MESSAGE_BOX_WEBSOCKET_SEND_RATE_LIMIT |
300 authenticated sends/minute/socket |
MESSAGE_BOX_WEBSOCKET_MAX_RECIPIENT_CONNECTIONS |
25 notification targets/message |
MESSAGE_BOX_NOTIFICATION_RECIPIENT_CONCURRENCY |
4 recipient notification workers |
MESSAGE_BOX_FCM_SEND_CONCURRENCY |
10 device-send workers/recipient |
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
for the complete shared edge policy.
From this directory:
cp .env.example .env
npm ci --ignore-scripts
npm rebuild better-sqlite3
docker compose up -d backend-mysql
npm run devUse a dedicated local-only SERVER_PRIVATE_KEY; never reuse a production key.
The Compose stack expects that value from the environment.
Run checks:
npm run typecheck
npm run lint
npm test
npm run test:coverage
npm run build
npm audit --audit-level=highThe separate client integration suite requires configured wallet, server, database, WebSocket, and overlay services and is intentionally opt-in. The server test suite also runs a self-contained three-user integration test with real Message Box clients, ProtoWallet identities, BRC-103 AuthSocket handshakes, encrypted shared-document messages, and disconnect/rejoin coverage.
Migrations in src/migrations/ run at startup. /healthz reports only process
liveness. /ready verifies database connectivity and returns 503 without
exposing dependency details while the service is unavailable.
Quota writes create their durable identity lock rows in a short autocommit
operation, then acquire those rows in stable order inside the message
transaction. MySQL/PXC serialization conflicts are retried with bounded
exponential backoff. Operators can tune the default five retries and 10–250 ms
delay range with MESSAGE_BOX_DB_DEADLOCK_RETRIES,
MESSAGE_BOX_DB_DEADLOCK_RETRY_BASE_MS, and
MESSAGE_BOX_DB_DEADLOCK_RETRY_MAX_MS; setting retries to 0 disables retry.
Body-payment replay claims and message inserts commit in the same database
transaction. Embedders that supply a custom replay store must now implement
the exported TransactionalPaymentReplayStore: retain the ordinary claim
method used by HTTP payment middleware and add claimInTransaction, using the
exact Knex transaction supplied by Message Box. An implementation that commits
the claim independently, delegates it to another database, or ignores the
transaction can consume a payment without storing its message and is not
supported. The built-in KnexPaymentReplayStore already implements this
contract; standalone container deployments require no configuration change.
Transactional body-payment claims are non-expiring because a recipient-only
payment has no server-wallet freshness verdict. The
MESSAGE_BOX_PAYMENT_REPLAY_TTL_DAYS setting applies only to ordinary
route-level BRC-105 claims; pruning must never remove body-payment claims.
Each new body-payment request with a server delivery output must receive a
newly accepted wallet verdict for that output. Message Box rejects
isMerge: true, including after a replay row expires or is pruned. A
prepared payment intent binds the transaction ID to a SHA-256 digest of the
canonical sender, destination messages, Atomic BEEF, and payment metadata
before wallet mutation. If a non-retryable replay-store or message-transaction
failure occurs after wallet acceptance, the accepted state is committed after
the failed transaction releases its connection. An exact retry resumes message
storage without internalizing again; the same transaction with any different
request is rejected. Completed requests retain normal duplicate-message and
replay semantics. A wallet call that returns isMerge: true without an
existing accepted intent still fails closed, so an old wallet transaction
cannot become a new message payment. A hard process termination in the narrow
window after wallet acceptance but before wallet_accepted is persisted still
leaves an ambiguous prepared intent; the service cannot safely infer whether
a later wallet merge belongs to that attempt or an older replay. Operators must
reconcile the wallet transaction and intent row before asking the payer to retry
or spend again. This recovery protocol therefore handles observed
non-retryable/commit failures, but does not claim crash-atomicity across the
wallet and message database.
Only the request attempt that created a prepared intent may advance or remove
it. Concurrent exact requests receive ERR_PAYMENT_IN_PROGRESS without calling
the wallet; exact retries resume only after the intent reaches
wallet_accepted or completed. Treat a stale prepared row as the ambiguous
hard-crash case above: do not delete or reassign it until wallet reconciliation
establishes whether that transaction was accepted.
After a production build, migrations can also be run explicitly:
npx --no-install knex --knexfile out/knexfile.js migrate:latest --env productionThe 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 active connections authenticated as the recipient
and joined to the exact destination room. Raw Socket.IO disconnects remove the
connection's identity and room state immediately. If the configured recipient
connection ceiling is reached, the newest matching connections are selected so
old tabs cannot exclude the currently joining client.
WebSocket sends reuse the HTTP handler's validation, fee, permission,
deduplication, and persistence behavior.
Process-wide, per-identity, per-connection-room, send, control-event, and
recipient-fan-out limits are enforced before expensive or authorization-
dependent event handling. Rejected unauthenticated events consume the same
per-connection rate budget as accepted events.
Push routing keeps the message ID in the platform data payload for application and notification-extension retrieval, but visible fallback alerts contain only generic text. Caller-selected routing identifiers are never lock-screen text.
Each WebSocket connection owns the BRC-103 session negotiated by its AuthSocket peer. The database-backed HTTP session manager is deliberately not shared with those connection-local peers: several tabs may authenticate as the same wallet, and an identity-keyed shared lookup must not select another tab's session nonce for acknowledgements or live delivery.
The in-memory connection map is process-local. Horizontally scaled deployments need sticky routing or an authenticated shared broker.
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.
docker build -t message-box-server:local .For production deployment, use the repository release workflow and immutable
image evidence rather than a local latest tag. See DEPLOYING.md.
The reviewed source artifact is
specs/messaging/message-box-http.yaml.
Runtime Swagger is useful for inspection; the repository OpenAPI artifact and
implementation tests are the contract evidence.
See LICENSE.txt.