@ottimis/ermes-node-sdk
v0.1.0
Published
Node/TypeScript SDK for the Ermes notification platform — port of ermes-php-sdk
Readme
ermes-node-sdk
Node/TypeScript SDK for the Ermes notification platform. Port of ermes-php-sdk with the same wire-level behavior, plus full TypeScript typings on config, request input, and response output.
Requirements
- Node.js 22+
- RSA private key (PEM) for signing user JWTs
- Credentials issued by the Ermes core admin:
apiKey,apiSecret,tenantKey,applicationId
Installation
npm install @ottimis/ermes-node-sdk
# or
pnpm add @ottimis/ermes-node-sdkQuick start
End-to-end integration in 4 steps:
1. Generate an RSA key pair
openssl genrsa -out ermes-private.pem 2048
openssl rsa -in ermes-private.pem -pubout -out ermes-public.pemShare ermes-public.pem (or your JWKS endpoint URL) with the Ermes core admin. Keep the private key in your backend — never expose it client-side.
2. Set environment variables
Create .env (do not commit):
NOTIFICATION_CORE_URL=https://ermes.yourcompany.com
NOTIFICATION_TENANT_KEY=myapp
NOTIFICATION_APPLICATION_ID=my-backoffice
NOTIFICATION_ISSUER=https://api.yourcompany.com
NOTIFICATION_API_KEY=ak_xxxxxxxxxxxxxxx
NOTIFICATION_API_SECRET=as_yyyyyyyyyyyyyyy
NOTIFICATION_RSA_PRIVATE_KEY_PATH=/run/secrets/ermes-private.pem
NOTIFICATION_KID=myapp-key-1Alternative (inline key, suitable for Heroku/Vercel/Docker secrets without volume mount):
NOTIFICATION_RSA_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----\n"3. Instantiate the client (single shared instance)
import { NotificationClient, NotificationConfig } from '@ottimis/ermes-node-sdk';
export const ermes = new NotificationClient(NotificationConfig.fromEnv());4. Expose JWKS + send your first event
// HTTP route: GET /.well-known/jwks.json
app.get('/.well-known/jwks.json', (_req, res) => res.json(ermes.getJwks()));
// Send an event
await ermes.sendEvent({
topic: 'contract.termination.completed',
title: 'Cessazione completata',
body: 'Contratto C-1234 elaborato.',
severity: 'success',
entity_type: 'contract',
entity_id: 'C-1234',
recipient_users: ['user_42'],
});That's it. Frontend connects via Socket.IO using a token from client.createUserToken(userId) — see User tokens.
Configuration
Option A — explicit (recommended for multi-project setups)
import { NotificationClient, NotificationConfig } from '@ottimis/ermes-node-sdk';
import * as fs from 'fs';
const config = new NotificationConfig({
coreUrl: 'https://ermes.yourcompany.com',
tenantKey: 'myapp',
applicationId: 'my-backoffice',
issuer: 'https://auth.yourcompany.com',
apiKey: 'ak_xxxxxxxxxxxxxxx',
apiSecret: 'as_yyyyyyyyyyyyyyy',
privateKeyPem: fs.readFileSync('/path/to/private.pem', 'utf8'),
kid: 'myapp-key-1',
});
const client = new NotificationClient(config);Option B — from environment variables
const client = new NotificationClient(NotificationConfig.fromEnv());| Env var | Required | Description |
|---|---|---|
| NOTIFICATION_CORE_URL | yes | Base URL of the Ermes core server |
| NOTIFICATION_TENANT_KEY | yes | Logical tenant identifier |
| NOTIFICATION_APPLICATION_ID | yes | Producer application identifier |
| NOTIFICATION_ISSUER | yes | iss claim in user JWTs |
| NOTIFICATION_API_KEY | yes | Basic auth key for event ingestion |
| NOTIFICATION_API_SECRET | yes | Basic auth secret for event ingestion |
| NOTIFICATION_RSA_PRIVATE_KEY | yes* | RSA private key PEM (inline, \n escaped) |
| NOTIFICATION_RSA_PRIVATE_KEY_PATH | yes* | Path to RSA private key PEM file |
| NOTIFICATION_KID | no | Key ID for JWKS (default: key-1) |
*Either NOTIFICATION_RSA_PRIVATE_KEY or NOTIFICATION_RSA_PRIVATE_KEY_PATH is required.
Missing or invalid configuration throws ErmesConfigurationError.
JWKS endpoint
Your backend must expose /.well-known/jwks.json so the Ermes core can validate user JWTs:
// in your HTTP handler (Express / Nest / Fastify):
const jwks = client.getJwks();
res.json(jwks);Returns a strongly-typed JwksDocument.
Sending events
import type { EventInput, SendEventResult } from '@ottimis/ermes-node-sdk';
const event: EventInput = {
topic: 'contract.termination.completed',
title: 'Cessazione completata',
body: 'La cessazione del contratto C-1234 è stata elaborata.',
severity: 'info',
entity_type: 'contract',
entity_id: 'C-1234',
recipient_users: ['user_42'],
payload: { contract_id: 'C-1234' },
event_name: 'notification.new', // optional — defaults to 'notification.new'
};
const result: SendEventResult = await client.sendEvent(event);
// result.eventId — generated `evt-...` id
// result.body — decoded response body (unknown)tenant_key, application_id, and event_id are injected automatically. If event_name is omitted it defaults to 'notification.new' — the event name the official Ermes FE client subscribes to. Override only if you know the consumer listens to a different name. Any non-202 response throws a typed error (see Error handling).
EventInput fields
| Field | Type | Required | Notes |
|---|---|---|---|
| topic | string | yes | Hierarchical dot-path identifier (e.g. contract.termination.completed). Used for filtering and routing. |
| title | string | yes | Human-readable title (or i18n key). |
| body | string | no | Human-readable body (or i18n key). |
| severity | 'info' \| 'warning' \| 'error' \| 'success' | no | Defaults to 'info' on the BE side. |
| entity_type | string | no | Domain entity type the notification refers to. |
| entity_id | string | no | Domain entity id. |
| recipient_users | string[] | yes | User ids to deliver to. |
| payload | Record<string, unknown> | no | Arbitrary structured data forwarded to the FE. |
| event_name | string | no | Defaults to 'notification.new'. Name the WS client subscribes to. |
User tokens (Socket.IO + inbox)
// Short form — token string only
const token = client.createUserToken('user_42');
// Full form — token + claims
const { token, info } = client.createUserTokenWithInfo('user_42');
// info.exp — Unix timestamp; current TTL is 9 years (parity with PHP SDK)
// Custom roles (default: ['operator'])
const adminToken = client.createUserToken('user_42', ['operator', 'admin']);Frontend Socket.IO connection:
import { io } from 'socket.io-client';
const socket = io('wss://ermes.yourcompany.com', {
auth: { token },
});
socket.on('notification.new', (notification) => {
// notification.title, notification.body, notification.payload, ...
});The FE client subscribes to notification.new — this is why event_name defaults to that value when sending. Mint the token server-side via createUserToken(userId) and ship it to the browser through your existing auth flow; never expose apiKey/apiSecret/privateKeyPem to the client.
Inbox proxy methods
All inbox methods return their response body directly on success and throw a typed error on any non-2xx status.
List notifications
const list = await client.getNotifications('user_42', {
status: 'unread',
page: 1,
limit: 20,
topic: 'contract.termination.completed',
});
// list.items — InboxItem[]
// list.pagination — { page, limit, total, nextCursor }Unread count
const { count } = await client.getUnreadCount('user_42');Sync (cursor-based)
const sync = await client.syncNotifications('user_42', { after: 'notif_1250', limit: 50 });
// sync.items — InboxItem[]
// sync.cursor — string | nullMark as read
await client.markAsRead('1b2c3d4e-5f60-4718-9abc-def012345678', 'user_42');
await client.markBulkRead(
['1b2c3d4e-5f60-4718-9abc-def012345678', '2c3d4e5f-6071-4829-abcd-ef0123456789'],
'user_42',
);
await client.markAllAsRead('user_42');All mark methods return Promise<void> on 200/204 and throw on any other status.
Error handling
All SDK methods follow Spring Boot–style typed exceptions. On any failure the SDK throws a subclass of ErmesError. Inspect with instanceof and switch on the class — never on string messages.
Hierarchy
Error
└── ErmesError // abstract base, all SDK errors extend this
├── ErmesHttpError // abstract, every HTTP non-2xx
│ ├── ErmesBadRequestError // 400
│ ├── ErmesUnauthorizedError // 401
│ ├── ErmesForbiddenError // 403
│ ├── ErmesNotFoundError // 404
│ ├── ErmesConflictError // 409
│ ├── ErmesValidationError // 422
│ ├── ErmesRateLimitError // 429 (+ retryAfterSeconds)
│ ├── ErmesInternalServerError // 500
│ ├── ErmesBadGatewayError // 502
│ ├── ErmesServiceUnavailableError// 503
│ ├── ErmesGatewayTimeoutError // 504
│ └── ErmesUnexpectedStatusError // fallback for unmapped statuses
├── ErmesNetworkError // DNS, connection refused, socket errors
│ └── ErmesTimeoutError // request aborted by timeout
├── ErmesConfigurationError // invalid config / missing env vars / non-RSA key
└── ErmesParseError // server returned non-JSON body when JSON was requiredEvery ErmesError exposes:
name— class name ("ErmesNotFoundError")code— stable machine-readable code ("ERMES_NOT_FOUND")message— human-readable diagnosticcause— original underlying error if any (e.g. wrapped axios error)
Every ErmesHttpError adds:
status— HTTP status coderesponseBody— parsed JSON body, ornullif non-JSONrequestUrl— full request URLrequestMethod—'GET'or'POST'
ErmesRateLimitError additionally exposes retryAfterSeconds (parsed from Retry-After).
Status → class mapping
| HTTP status | Error class | Code |
|---|---|---|
| 400 | ErmesBadRequestError | ERMES_BAD_REQUEST |
| 401 | ErmesUnauthorizedError | ERMES_UNAUTHORIZED |
| 403 | ErmesForbiddenError | ERMES_FORBIDDEN |
| 404 | ErmesNotFoundError | ERMES_NOT_FOUND |
| 409 | ErmesConflictError | ERMES_CONFLICT |
| 422 | ErmesValidationError | ERMES_VALIDATION |
| 429 | ErmesRateLimitError | ERMES_RATE_LIMIT |
| 500 | ErmesInternalServerError | ERMES_INTERNAL |
| 502 | ErmesBadGatewayError | ERMES_BAD_GATEWAY |
| 503 | ErmesServiceUnavailableError | ERMES_UNAVAILABLE |
| 504 | ErmesGatewayTimeoutError | ERMES_GATEWAY_TIMEOUT |
| other | ErmesUnexpectedStatusError | ERMES_UNEXPECTED_STATUS |
Example
import {
ErmesHttpError,
ErmesUnauthorizedError,
ErmesValidationError,
ErmesRateLimitError,
ErmesNetworkError,
ErmesTimeoutError,
} from '@ottimis/ermes-node-sdk';
try {
const inbox = await client.getNotifications('user_42');
return inbox.items;
} catch (err) {
if (err instanceof ErmesUnauthorizedError) {
// re-auth flow
throw err;
}
if (err instanceof ErmesValidationError) {
// err.responseBody contains the server's validation payload
logger.warn({ body: err.responseBody }, 'validation failed');
throw err;
}
if (err instanceof ErmesRateLimitError) {
await sleep((err.retryAfterSeconds ?? 1) * 1000);
// retry...
}
if (err instanceof ErmesTimeoutError) {
// request timed out before any response
} else if (err instanceof ErmesNetworkError) {
// DNS / TCP / TLS failure
} else if (err instanceof ErmesHttpError) {
// any other HTTP failure — err.status, err.responseBody available
}
throw err;
}NestJS / Express integration
Both NestJS exception filters and Express error middlewares can pattern-match on these classes the same way you would on HttpException subclasses — e.g. map ErmesUnauthorizedError → 401, ErmesValidationError → 422, anything else → 502.
Type exports
All public types and error classes are exported from the package entry point:
import {
// client
NotificationClient, NotificationConfig,
// errors (classes — runtime values)
ErmesError, ErmesHttpError,
ErmesBadRequestError, ErmesUnauthorizedError, ErmesForbiddenError,
ErmesNotFoundError, ErmesConflictError, ErmesValidationError, ErmesRateLimitError,
ErmesInternalServerError, ErmesBadGatewayError, ErmesServiceUnavailableError,
ErmesGatewayTimeoutError, ErmesUnexpectedStatusError,
ErmesNetworkError, ErmesTimeoutError,
ErmesConfigurationError, ErmesParseError,
mapHttpError,
} from '@ottimis/ermes-node-sdk';
import type {
// config
NotificationConfigOptions,
// events
EventInput, EventSeverity, SendEventResult,
// inbox
InboxItem, Pagination, ListParams, InboxListResponse,
SyncParams, SyncResponse, UnreadCountResponse,
// jwt / jwks
JwtClaims, TokenWithInfo, JwksKey, JwksDocument,
// http
HttpResponseRaw,
// error option shapes
ErmesErrorOptions, ErmesHttpErrorOptions, ErmesHttpMethod,
ErmesRateLimitErrorOptions, ErmesParseErrorOptions, MapHttpErrorArgs,
} from '@ottimis/ermes-node-sdk';