@insureco/relay 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ /** Built-in template names available in InsureRelay */
2
+ type BuiltInTemplate = 'welcome' | 'invitation' | 'password-reset' | 'magic-link';
3
+ /** Recipient for an email or SMS message */
4
+ interface Recipient {
5
+ /** Email address (required for email channel) */
6
+ email?: string;
7
+ /** Phone number in E.164 format, e.g. +15551234567 (required for SMS channel) */
8
+ phone?: string;
9
+ /** Recipient display name */
10
+ name?: string;
11
+ }
12
+ /** Options for sending a message */
13
+ interface SendOptions {
14
+ /** Override default sender address */
15
+ from?: string;
16
+ /** Reply-to address */
17
+ replyTo?: string;
18
+ /** Message priority */
19
+ priority?: 'high' | 'normal' | 'low';
20
+ }
21
+ /** Metadata attached to a message for tracking */
22
+ interface MessageMetadata {
23
+ /** Name of the calling service, e.g. 'bio-id' */
24
+ sourceService: string;
25
+ /** Action that triggered the send, e.g. 'user_registered' */
26
+ sourceAction: string;
27
+ /** Correlation ID for tracing across services */
28
+ correlationId?: string;
29
+ }
30
+ /** Raw content payload for template-less sends */
31
+ interface ContentPayload {
32
+ /** Email subject (required for email channel) */
33
+ subject?: string;
34
+ /** Raw HTML body (email only) */
35
+ html?: string;
36
+ /** Plain text body (required for SMS, optional fallback for email) */
37
+ text?: string;
38
+ }
39
+ /** Full request body for POST /api/relay/send */
40
+ interface SendRequest {
41
+ template?: string;
42
+ channel: 'email' | 'sms';
43
+ content?: ContentPayload;
44
+ recipient: Recipient;
45
+ data: Record<string, unknown>;
46
+ options?: SendOptions;
47
+ metadata?: MessageMetadata;
48
+ }
49
+ /** Successful send response */
50
+ interface SendResponse {
51
+ messageId: string;
52
+ channel: 'email' | 'sms';
53
+ status: 'sent';
54
+ providerMessageId: string;
55
+ }
56
+ /** Message status from GET /api/relay/status/:id */
57
+ interface MessageStatus {
58
+ messageId: string;
59
+ channel: 'email' | 'sms';
60
+ template: string;
61
+ status: 'sent' | 'delivered' | 'failed';
62
+ recipient: Recipient;
63
+ sender: string;
64
+ sentAt: string;
65
+ providerMessageId: string;
66
+ }
67
+ /** API response wrapper */
68
+ interface ApiResponse<T> {
69
+ success: boolean;
70
+ data?: T;
71
+ error?: string;
72
+ details?: Record<string, unknown>;
73
+ }
74
+ /** Configuration for the RelayClient */
75
+ interface RelayClientConfig {
76
+ /** Shared JWT secret for minting service tokens (required when using Janus) */
77
+ jwtSecret?: string;
78
+ /** Program/org ID included in the JWT (default: derived from service name) */
79
+ programId?: string;
80
+ /** Janus gateway URL (default: http://janus.janus-prod.svc.cluster.local:3000) */
81
+ janusUrl?: string;
82
+ /** Direct relay URL for local dev (bypasses Janus, uses X-Internal-Key auth) */
83
+ relayUrl?: string;
84
+ /** Internal API key for direct relay access */
85
+ internalKey?: string;
86
+ /** Async function returning a Bearer token (e.g. Bio-ID client_credentials). Used in direct mode. */
87
+ accessTokenFn?: () => Promise<string>;
88
+ /** JWT token lifetime in seconds (default: 300 = 5 minutes) */
89
+ tokenTtlSeconds?: number;
90
+ /**
91
+ * Number of retry attempts on transient failures (default: 2).
92
+ * Note: Retries on POST /send may cause duplicate messages if the server
93
+ * processed the request but the response was lost. Set to 0 if duplicates
94
+ * are unacceptable.
95
+ */
96
+ retries?: number;
97
+ /** Request timeout in milliseconds (default: 10000) */
98
+ timeoutMs?: number;
99
+ /** Source service name for metadata (default: programId) */
100
+ sourceService?: string;
101
+ }
102
+ /** Base options shared by sendEmail and sendSMS */
103
+ interface BaseSendOptions {
104
+ /** Template name (built-in or custom). Required unless content is provided. */
105
+ template?: BuiltInTemplate | (string & {});
106
+ /** Raw content for template-less sends. Required unless template is provided. */
107
+ content?: ContentPayload;
108
+ /** Recipient */
109
+ to: Recipient;
110
+ /** Template variables (used with template mode) */
111
+ data?: Record<string, unknown>;
112
+ /** Optional send options */
113
+ options?: SendOptions;
114
+ /** Optional metadata overrides */
115
+ metadata?: Partial<MessageMetadata>;
116
+ }
117
+ /** Options for sendEmail — requires to.email */
118
+ interface SendEmailOptions extends BaseSendOptions {
119
+ }
120
+ /** Options for sendSMS — requires to.phone */
121
+ interface SendSMSOptions extends BaseSendOptions {
122
+ }
123
+
124
+ declare class RelayError extends Error {
125
+ readonly statusCode: number;
126
+ readonly code?: string | undefined;
127
+ readonly details?: Record<string, unknown> | undefined;
128
+ constructor(message: string, statusCode: number, code?: string | undefined, details?: Record<string, unknown> | undefined);
129
+ }
130
+ declare class RelayClient {
131
+ private readonly config;
132
+ private cachedToken;
133
+ constructor(config: RelayClientConfig);
134
+ /**
135
+ * Create a RelayClient from environment variables.
136
+ *
137
+ * Reads: JANUS_URL, JWT_SECRET, PROGRAM_ID, RELAY_DIRECT_URL, RELAY_INTERNAL_KEY
138
+ */
139
+ static fromEnv(): RelayClient;
140
+ /**
141
+ * Send an email through InsureRelay.
142
+ * Provide either `template` (with `data`) or `content` (raw HTML/text).
143
+ */
144
+ sendEmail(options: SendEmailOptions): Promise<SendResponse>;
145
+ /**
146
+ * Send an SMS through InsureRelay.
147
+ * Provide either `template` (with `data`) or `content` (raw text).
148
+ */
149
+ sendSMS(options: SendSMSOptions): Promise<SendResponse>;
150
+ /**
151
+ * Get the delivery status of a sent message.
152
+ */
153
+ getStatus(messageId: string): Promise<MessageStatus>;
154
+ /**
155
+ * List available templates on the relay service.
156
+ */
157
+ listTemplates(): Promise<string[]>;
158
+ /**
159
+ * Low-level send — use sendEmail() or sendSMS() instead.
160
+ */
161
+ send(body: SendRequest): Promise<SendResponse>;
162
+ private request;
163
+ private buildRequest;
164
+ private getToken;
165
+ }
166
+
167
+ /**
168
+ * Mint a short-lived HS256 JWT for service-to-service auth through Janus.
169
+ * Uses only Node.js built-in crypto — zero dependencies.
170
+ */
171
+ declare function mintServiceJwt(secret: string, programId: string, ttlSeconds?: number): string;
172
+ /**
173
+ * Check if a JWT is expired or will expire within the given buffer (seconds).
174
+ */
175
+ declare function isTokenExpired(token: string, bufferSeconds?: number): boolean;
176
+
177
+ export { type ApiResponse, type BuiltInTemplate, type ContentPayload, type MessageMetadata, type MessageStatus, type Recipient, RelayClient, type RelayClientConfig, RelayError, type SendEmailOptions, type SendOptions, type SendRequest, type SendResponse, type SendSMSOptions, isTokenExpired, mintServiceJwt };
@@ -0,0 +1,177 @@
1
+ /** Built-in template names available in InsureRelay */
2
+ type BuiltInTemplate = 'welcome' | 'invitation' | 'password-reset' | 'magic-link';
3
+ /** Recipient for an email or SMS message */
4
+ interface Recipient {
5
+ /** Email address (required for email channel) */
6
+ email?: string;
7
+ /** Phone number in E.164 format, e.g. +15551234567 (required for SMS channel) */
8
+ phone?: string;
9
+ /** Recipient display name */
10
+ name?: string;
11
+ }
12
+ /** Options for sending a message */
13
+ interface SendOptions {
14
+ /** Override default sender address */
15
+ from?: string;
16
+ /** Reply-to address */
17
+ replyTo?: string;
18
+ /** Message priority */
19
+ priority?: 'high' | 'normal' | 'low';
20
+ }
21
+ /** Metadata attached to a message for tracking */
22
+ interface MessageMetadata {
23
+ /** Name of the calling service, e.g. 'bio-id' */
24
+ sourceService: string;
25
+ /** Action that triggered the send, e.g. 'user_registered' */
26
+ sourceAction: string;
27
+ /** Correlation ID for tracing across services */
28
+ correlationId?: string;
29
+ }
30
+ /** Raw content payload for template-less sends */
31
+ interface ContentPayload {
32
+ /** Email subject (required for email channel) */
33
+ subject?: string;
34
+ /** Raw HTML body (email only) */
35
+ html?: string;
36
+ /** Plain text body (required for SMS, optional fallback for email) */
37
+ text?: string;
38
+ }
39
+ /** Full request body for POST /api/relay/send */
40
+ interface SendRequest {
41
+ template?: string;
42
+ channel: 'email' | 'sms';
43
+ content?: ContentPayload;
44
+ recipient: Recipient;
45
+ data: Record<string, unknown>;
46
+ options?: SendOptions;
47
+ metadata?: MessageMetadata;
48
+ }
49
+ /** Successful send response */
50
+ interface SendResponse {
51
+ messageId: string;
52
+ channel: 'email' | 'sms';
53
+ status: 'sent';
54
+ providerMessageId: string;
55
+ }
56
+ /** Message status from GET /api/relay/status/:id */
57
+ interface MessageStatus {
58
+ messageId: string;
59
+ channel: 'email' | 'sms';
60
+ template: string;
61
+ status: 'sent' | 'delivered' | 'failed';
62
+ recipient: Recipient;
63
+ sender: string;
64
+ sentAt: string;
65
+ providerMessageId: string;
66
+ }
67
+ /** API response wrapper */
68
+ interface ApiResponse<T> {
69
+ success: boolean;
70
+ data?: T;
71
+ error?: string;
72
+ details?: Record<string, unknown>;
73
+ }
74
+ /** Configuration for the RelayClient */
75
+ interface RelayClientConfig {
76
+ /** Shared JWT secret for minting service tokens (required when using Janus) */
77
+ jwtSecret?: string;
78
+ /** Program/org ID included in the JWT (default: derived from service name) */
79
+ programId?: string;
80
+ /** Janus gateway URL (default: http://janus.janus-prod.svc.cluster.local:3000) */
81
+ janusUrl?: string;
82
+ /** Direct relay URL for local dev (bypasses Janus, uses X-Internal-Key auth) */
83
+ relayUrl?: string;
84
+ /** Internal API key for direct relay access */
85
+ internalKey?: string;
86
+ /** Async function returning a Bearer token (e.g. Bio-ID client_credentials). Used in direct mode. */
87
+ accessTokenFn?: () => Promise<string>;
88
+ /** JWT token lifetime in seconds (default: 300 = 5 minutes) */
89
+ tokenTtlSeconds?: number;
90
+ /**
91
+ * Number of retry attempts on transient failures (default: 2).
92
+ * Note: Retries on POST /send may cause duplicate messages if the server
93
+ * processed the request but the response was lost. Set to 0 if duplicates
94
+ * are unacceptable.
95
+ */
96
+ retries?: number;
97
+ /** Request timeout in milliseconds (default: 10000) */
98
+ timeoutMs?: number;
99
+ /** Source service name for metadata (default: programId) */
100
+ sourceService?: string;
101
+ }
102
+ /** Base options shared by sendEmail and sendSMS */
103
+ interface BaseSendOptions {
104
+ /** Template name (built-in or custom). Required unless content is provided. */
105
+ template?: BuiltInTemplate | (string & {});
106
+ /** Raw content for template-less sends. Required unless template is provided. */
107
+ content?: ContentPayload;
108
+ /** Recipient */
109
+ to: Recipient;
110
+ /** Template variables (used with template mode) */
111
+ data?: Record<string, unknown>;
112
+ /** Optional send options */
113
+ options?: SendOptions;
114
+ /** Optional metadata overrides */
115
+ metadata?: Partial<MessageMetadata>;
116
+ }
117
+ /** Options for sendEmail — requires to.email */
118
+ interface SendEmailOptions extends BaseSendOptions {
119
+ }
120
+ /** Options for sendSMS — requires to.phone */
121
+ interface SendSMSOptions extends BaseSendOptions {
122
+ }
123
+
124
+ declare class RelayError extends Error {
125
+ readonly statusCode: number;
126
+ readonly code?: string | undefined;
127
+ readonly details?: Record<string, unknown> | undefined;
128
+ constructor(message: string, statusCode: number, code?: string | undefined, details?: Record<string, unknown> | undefined);
129
+ }
130
+ declare class RelayClient {
131
+ private readonly config;
132
+ private cachedToken;
133
+ constructor(config: RelayClientConfig);
134
+ /**
135
+ * Create a RelayClient from environment variables.
136
+ *
137
+ * Reads: JANUS_URL, JWT_SECRET, PROGRAM_ID, RELAY_DIRECT_URL, RELAY_INTERNAL_KEY
138
+ */
139
+ static fromEnv(): RelayClient;
140
+ /**
141
+ * Send an email through InsureRelay.
142
+ * Provide either `template` (with `data`) or `content` (raw HTML/text).
143
+ */
144
+ sendEmail(options: SendEmailOptions): Promise<SendResponse>;
145
+ /**
146
+ * Send an SMS through InsureRelay.
147
+ * Provide either `template` (with `data`) or `content` (raw text).
148
+ */
149
+ sendSMS(options: SendSMSOptions): Promise<SendResponse>;
150
+ /**
151
+ * Get the delivery status of a sent message.
152
+ */
153
+ getStatus(messageId: string): Promise<MessageStatus>;
154
+ /**
155
+ * List available templates on the relay service.
156
+ */
157
+ listTemplates(): Promise<string[]>;
158
+ /**
159
+ * Low-level send — use sendEmail() or sendSMS() instead.
160
+ */
161
+ send(body: SendRequest): Promise<SendResponse>;
162
+ private request;
163
+ private buildRequest;
164
+ private getToken;
165
+ }
166
+
167
+ /**
168
+ * Mint a short-lived HS256 JWT for service-to-service auth through Janus.
169
+ * Uses only Node.js built-in crypto — zero dependencies.
170
+ */
171
+ declare function mintServiceJwt(secret: string, programId: string, ttlSeconds?: number): string;
172
+ /**
173
+ * Check if a JWT is expired or will expire within the given buffer (seconds).
174
+ */
175
+ declare function isTokenExpired(token: string, bufferSeconds?: number): boolean;
176
+
177
+ export { type ApiResponse, type BuiltInTemplate, type ContentPayload, type MessageMetadata, type MessageStatus, type Recipient, RelayClient, type RelayClientConfig, RelayError, type SendEmailOptions, type SendOptions, type SendRequest, type SendResponse, type SendSMSOptions, isTokenExpired, mintServiceJwt };
package/dist/index.js ADDED
@@ -0,0 +1,357 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ RelayClient: () => RelayClient,
24
+ RelayError: () => RelayError,
25
+ isTokenExpired: () => isTokenExpired,
26
+ mintServiceJwt: () => mintServiceJwt
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+
30
+ // src/jwt.ts
31
+ var import_node_crypto = require("crypto");
32
+ function base64url(input) {
33
+ const buf = typeof input === "string" ? Buffer.from(input) : input;
34
+ return buf.toString("base64url");
35
+ }
36
+ function mintServiceJwt(secret, programId, ttlSeconds = 300) {
37
+ if (!secret || secret.length < 32) {
38
+ throw new Error("iec-relay: JWT secret must be at least 32 characters (256 bits) for HS256");
39
+ }
40
+ const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
41
+ const now = Math.floor(Date.now() / 1e3);
42
+ const payload = {
43
+ iss: `iec-relay:${programId}`,
44
+ programId,
45
+ scopes: ["service:relay"],
46
+ iat: now,
47
+ exp: now + ttlSeconds
48
+ };
49
+ const encodedPayload = base64url(JSON.stringify(payload));
50
+ const signature = (0, import_node_crypto.createHmac)("sha256", secret).update(`${header}.${encodedPayload}`).digest("base64url");
51
+ return `${header}.${encodedPayload}.${signature}`;
52
+ }
53
+ function isTokenExpired(token, bufferSeconds = 30) {
54
+ try {
55
+ const parts = token.split(".");
56
+ if (parts.length !== 3) return true;
57
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
58
+ const now = Math.floor(Date.now() / 1e3);
59
+ return payload.exp <= now + bufferSeconds;
60
+ } catch {
61
+ return true;
62
+ }
63
+ }
64
+
65
+ // src/client.ts
66
+ var DEFAULT_JANUS_URL = "http://janus.janus-prod.svc.cluster.local:3000";
67
+ var RELAY_API_PATH = "/api/relay";
68
+ var DEFAULT_TIMEOUT_MS = 1e4;
69
+ var RelayError = class extends Error {
70
+ constructor(message, statusCode, code, details) {
71
+ super(message);
72
+ this.statusCode = statusCode;
73
+ this.code = code;
74
+ this.details = details;
75
+ this.name = "RelayError";
76
+ }
77
+ };
78
+ var RelayClient = class _RelayClient {
79
+ config;
80
+ cachedToken = null;
81
+ constructor(config) {
82
+ if (!config.jwtSecret && !config.relayUrl) {
83
+ throw new Error(
84
+ "iec-relay: Either jwtSecret (for Janus) or relayUrl (for direct) is required"
85
+ );
86
+ }
87
+ if (config.jwtSecret && !config.programId) {
88
+ throw new Error("iec-relay: programId is required when using jwtSecret");
89
+ }
90
+ this.config = {
91
+ ...config,
92
+ retries: config.retries ?? 2,
93
+ tokenTtlSeconds: config.tokenTtlSeconds ?? 300,
94
+ sourceService: config.sourceService ?? config.programId ?? "unknown",
95
+ timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS
96
+ };
97
+ }
98
+ /**
99
+ * Create a RelayClient from environment variables.
100
+ *
101
+ * Reads: JANUS_URL, JWT_SECRET, PROGRAM_ID, RELAY_DIRECT_URL, RELAY_INTERNAL_KEY
102
+ */
103
+ static fromEnv() {
104
+ const relayUrl = process.env.RELAY_DIRECT_URL;
105
+ const internalKey = process.env.RELAY_INTERNAL_KEY;
106
+ if (relayUrl) {
107
+ return new _RelayClient({
108
+ relayUrl,
109
+ internalKey,
110
+ sourceService: process.env.PROGRAM_ID ?? process.env.SERVICE_NAME
111
+ });
112
+ }
113
+ const jwtSecret = process.env.JWT_SECRET;
114
+ if (!jwtSecret) {
115
+ throw new Error(
116
+ "iec-relay: Set JWT_SECRET + PROGRAM_ID (for Janus) or RELAY_DIRECT_URL (for direct)"
117
+ );
118
+ }
119
+ return new _RelayClient({
120
+ jwtSecret,
121
+ programId: process.env.PROGRAM_ID,
122
+ janusUrl: process.env.JANUS_URL
123
+ });
124
+ }
125
+ /**
126
+ * Send an email through InsureRelay.
127
+ * Provide either `template` (with `data`) or `content` (raw HTML/text).
128
+ */
129
+ async sendEmail(options) {
130
+ if (!options.to.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(options.to.email)) {
131
+ throw new RelayError(
132
+ "Valid recipient email is required for email channel",
133
+ 400,
134
+ "VALIDATION_ERROR"
135
+ );
136
+ }
137
+ if (!options.template && !options.content) {
138
+ throw new RelayError(
139
+ "Either template or content is required",
140
+ 400,
141
+ "VALIDATION_ERROR"
142
+ );
143
+ }
144
+ if (options.template && options.content) {
145
+ throw new RelayError(
146
+ "Provide template or content, not both",
147
+ 400,
148
+ "VALIDATION_ERROR"
149
+ );
150
+ }
151
+ if (options.content) {
152
+ if (!options.content.subject) {
153
+ throw new RelayError(
154
+ "Email content requires a subject",
155
+ 400,
156
+ "VALIDATION_ERROR"
157
+ );
158
+ }
159
+ if (!options.content.html && !options.content.text) {
160
+ throw new RelayError(
161
+ "Email content requires either html or text",
162
+ 400,
163
+ "VALIDATION_ERROR"
164
+ );
165
+ }
166
+ }
167
+ const sourceAction = options.metadata?.sourceAction ?? options.template ?? "raw_email";
168
+ return this.send({
169
+ ...options.template ? { template: options.template } : {},
170
+ ...options.content ? { content: options.content } : {},
171
+ channel: "email",
172
+ recipient: options.to,
173
+ data: options.data ?? {},
174
+ options: options.options,
175
+ metadata: {
176
+ sourceService: options.metadata?.sourceService ?? this.config.sourceService,
177
+ sourceAction,
178
+ correlationId: options.metadata?.correlationId
179
+ }
180
+ });
181
+ }
182
+ /**
183
+ * Send an SMS through InsureRelay.
184
+ * Provide either `template` (with `data`) or `content` (raw text).
185
+ */
186
+ async sendSMS(options) {
187
+ if (!options.to.phone || !/^\+[1-9]\d{1,14}$/.test(options.to.phone)) {
188
+ throw new RelayError(
189
+ "Recipient phone must be in E.164 format (e.g., +15551234567)",
190
+ 400,
191
+ "VALIDATION_ERROR"
192
+ );
193
+ }
194
+ if (!options.template && !options.content) {
195
+ throw new RelayError(
196
+ "Either template or content is required",
197
+ 400,
198
+ "VALIDATION_ERROR"
199
+ );
200
+ }
201
+ if (options.template && options.content) {
202
+ throw new RelayError(
203
+ "Provide template or content, not both",
204
+ 400,
205
+ "VALIDATION_ERROR"
206
+ );
207
+ }
208
+ if (options.content && !options.content.text) {
209
+ throw new RelayError(
210
+ "SMS content requires text",
211
+ 400,
212
+ "VALIDATION_ERROR"
213
+ );
214
+ }
215
+ const sourceAction = options.metadata?.sourceAction ?? options.template ?? "raw_sms";
216
+ return this.send({
217
+ ...options.template ? { template: options.template } : {},
218
+ ...options.content ? { content: options.content } : {},
219
+ channel: "sms",
220
+ recipient: options.to,
221
+ data: options.data ?? {},
222
+ options: options.options,
223
+ metadata: {
224
+ sourceService: options.metadata?.sourceService ?? this.config.sourceService,
225
+ sourceAction,
226
+ correlationId: options.metadata?.correlationId
227
+ }
228
+ });
229
+ }
230
+ /**
231
+ * Get the delivery status of a sent message.
232
+ */
233
+ async getStatus(messageId) {
234
+ return this.request("GET", `/status/${messageId}`);
235
+ }
236
+ /**
237
+ * List available templates on the relay service.
238
+ */
239
+ async listTemplates() {
240
+ const response = await this.request("GET", "/templates");
241
+ return response.templates;
242
+ }
243
+ /**
244
+ * Low-level send — use sendEmail() or sendSMS() instead.
245
+ */
246
+ async send(body) {
247
+ return this.request("POST", "/send", body);
248
+ }
249
+ async request(method, path, body, attempt = 0) {
250
+ const { url, headers } = await this.buildRequest(path);
251
+ const fetchOptions = {
252
+ method,
253
+ headers: {
254
+ ...headers,
255
+ ...body ? { "Content-Type": "application/json" } : {}
256
+ },
257
+ ...body ? { body: JSON.stringify(body) } : {},
258
+ signal: AbortSignal.timeout(this.config.timeoutMs)
259
+ };
260
+ let response;
261
+ try {
262
+ response = await fetch(url, fetchOptions);
263
+ } catch (err) {
264
+ if (attempt < this.config.retries) {
265
+ const baseDelay = Math.min(1e3 * 2 ** attempt, 5e3);
266
+ const delay = baseDelay * (0.5 + Math.random() * 0.5);
267
+ await sleep(delay);
268
+ return this.request(method, path, body, attempt + 1);
269
+ }
270
+ const isTimeout = err instanceof DOMException && err.name === "TimeoutError";
271
+ const message = isTimeout ? `Request timed out after ${this.config.timeoutMs}ms` : err instanceof Error ? err.message : "Network error";
272
+ throw new RelayError(
273
+ `Failed to reach InsureRelay: ${message}`,
274
+ 0,
275
+ isTimeout ? "TIMEOUT" : "NETWORK_ERROR"
276
+ );
277
+ }
278
+ let json;
279
+ try {
280
+ json = await response.json();
281
+ } catch {
282
+ if (response.status >= 500 && attempt < this.config.retries) {
283
+ const baseDelay = Math.min(1e3 * 2 ** attempt, 5e3);
284
+ const delay = baseDelay * (0.5 + Math.random() * 0.5);
285
+ await sleep(delay);
286
+ return this.request(method, path, body, attempt + 1);
287
+ }
288
+ throw new RelayError(
289
+ `InsureRelay returned ${response.status} with non-JSON body`,
290
+ response.status,
291
+ "PARSE_ERROR"
292
+ );
293
+ }
294
+ if (!response.ok) {
295
+ if (response.status >= 500 && attempt < this.config.retries) {
296
+ const baseDelay = Math.min(1e3 * 2 ** attempt, 5e3);
297
+ const delay = baseDelay * (0.5 + Math.random() * 0.5);
298
+ await sleep(delay);
299
+ return this.request(method, path, body, attempt + 1);
300
+ }
301
+ throw new RelayError(
302
+ json.error ?? `InsureRelay returned ${response.status}`,
303
+ response.status,
304
+ json.error,
305
+ json.details
306
+ );
307
+ }
308
+ if (!json.data) {
309
+ throw new RelayError("InsureRelay returned success but no data", 500, "EMPTY_RESPONSE");
310
+ }
311
+ return json.data;
312
+ }
313
+ async buildRequest(path) {
314
+ const headers = {};
315
+ if (this.config.relayUrl) {
316
+ const url2 = `${this.config.relayUrl}${path}`;
317
+ if (this.config.accessTokenFn) {
318
+ headers["Authorization"] = `Bearer ${await this.config.accessTokenFn()}`;
319
+ } else if (this.config.internalKey) {
320
+ headers["X-Internal-Key"] = this.config.internalKey;
321
+ }
322
+ return { url: url2, headers };
323
+ }
324
+ const baseUrl = this.config.janusUrl ?? DEFAULT_JANUS_URL;
325
+ const url = `${baseUrl}${RELAY_API_PATH}${path}`;
326
+ headers["Authorization"] = `Bearer ${this.getToken()}`;
327
+ return { url, headers };
328
+ }
329
+ getToken() {
330
+ if (this.cachedToken && !isTokenExpired(this.cachedToken)) {
331
+ return this.cachedToken;
332
+ }
333
+ if (!this.config.jwtSecret || !this.config.programId) {
334
+ throw new RelayError(
335
+ "jwtSecret and programId are required for Janus auth",
336
+ 500,
337
+ "CONFIG_ERROR"
338
+ );
339
+ }
340
+ this.cachedToken = mintServiceJwt(
341
+ this.config.jwtSecret,
342
+ this.config.programId,
343
+ this.config.tokenTtlSeconds
344
+ );
345
+ return this.cachedToken;
346
+ }
347
+ };
348
+ function sleep(ms) {
349
+ return new Promise((resolve) => setTimeout(resolve, ms));
350
+ }
351
+ // Annotate the CommonJS export names for ESM import in node:
352
+ 0 && (module.exports = {
353
+ RelayClient,
354
+ RelayError,
355
+ isTokenExpired,
356
+ mintServiceJwt
357
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,327 @@
1
+ // src/jwt.ts
2
+ import { createHmac } from "crypto";
3
+ function base64url(input) {
4
+ const buf = typeof input === "string" ? Buffer.from(input) : input;
5
+ return buf.toString("base64url");
6
+ }
7
+ function mintServiceJwt(secret, programId, ttlSeconds = 300) {
8
+ if (!secret || secret.length < 32) {
9
+ throw new Error("iec-relay: JWT secret must be at least 32 characters (256 bits) for HS256");
10
+ }
11
+ const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
12
+ const now = Math.floor(Date.now() / 1e3);
13
+ const payload = {
14
+ iss: `iec-relay:${programId}`,
15
+ programId,
16
+ scopes: ["service:relay"],
17
+ iat: now,
18
+ exp: now + ttlSeconds
19
+ };
20
+ const encodedPayload = base64url(JSON.stringify(payload));
21
+ const signature = createHmac("sha256", secret).update(`${header}.${encodedPayload}`).digest("base64url");
22
+ return `${header}.${encodedPayload}.${signature}`;
23
+ }
24
+ function isTokenExpired(token, bufferSeconds = 30) {
25
+ try {
26
+ const parts = token.split(".");
27
+ if (parts.length !== 3) return true;
28
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
29
+ const now = Math.floor(Date.now() / 1e3);
30
+ return payload.exp <= now + bufferSeconds;
31
+ } catch {
32
+ return true;
33
+ }
34
+ }
35
+
36
+ // src/client.ts
37
+ var DEFAULT_JANUS_URL = "http://janus.janus-prod.svc.cluster.local:3000";
38
+ var RELAY_API_PATH = "/api/relay";
39
+ var DEFAULT_TIMEOUT_MS = 1e4;
40
+ var RelayError = class extends Error {
41
+ constructor(message, statusCode, code, details) {
42
+ super(message);
43
+ this.statusCode = statusCode;
44
+ this.code = code;
45
+ this.details = details;
46
+ this.name = "RelayError";
47
+ }
48
+ };
49
+ var RelayClient = class _RelayClient {
50
+ config;
51
+ cachedToken = null;
52
+ constructor(config) {
53
+ if (!config.jwtSecret && !config.relayUrl) {
54
+ throw new Error(
55
+ "iec-relay: Either jwtSecret (for Janus) or relayUrl (for direct) is required"
56
+ );
57
+ }
58
+ if (config.jwtSecret && !config.programId) {
59
+ throw new Error("iec-relay: programId is required when using jwtSecret");
60
+ }
61
+ this.config = {
62
+ ...config,
63
+ retries: config.retries ?? 2,
64
+ tokenTtlSeconds: config.tokenTtlSeconds ?? 300,
65
+ sourceService: config.sourceService ?? config.programId ?? "unknown",
66
+ timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS
67
+ };
68
+ }
69
+ /**
70
+ * Create a RelayClient from environment variables.
71
+ *
72
+ * Reads: JANUS_URL, JWT_SECRET, PROGRAM_ID, RELAY_DIRECT_URL, RELAY_INTERNAL_KEY
73
+ */
74
+ static fromEnv() {
75
+ const relayUrl = process.env.RELAY_DIRECT_URL;
76
+ const internalKey = process.env.RELAY_INTERNAL_KEY;
77
+ if (relayUrl) {
78
+ return new _RelayClient({
79
+ relayUrl,
80
+ internalKey,
81
+ sourceService: process.env.PROGRAM_ID ?? process.env.SERVICE_NAME
82
+ });
83
+ }
84
+ const jwtSecret = process.env.JWT_SECRET;
85
+ if (!jwtSecret) {
86
+ throw new Error(
87
+ "iec-relay: Set JWT_SECRET + PROGRAM_ID (for Janus) or RELAY_DIRECT_URL (for direct)"
88
+ );
89
+ }
90
+ return new _RelayClient({
91
+ jwtSecret,
92
+ programId: process.env.PROGRAM_ID,
93
+ janusUrl: process.env.JANUS_URL
94
+ });
95
+ }
96
+ /**
97
+ * Send an email through InsureRelay.
98
+ * Provide either `template` (with `data`) or `content` (raw HTML/text).
99
+ */
100
+ async sendEmail(options) {
101
+ if (!options.to.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(options.to.email)) {
102
+ throw new RelayError(
103
+ "Valid recipient email is required for email channel",
104
+ 400,
105
+ "VALIDATION_ERROR"
106
+ );
107
+ }
108
+ if (!options.template && !options.content) {
109
+ throw new RelayError(
110
+ "Either template or content is required",
111
+ 400,
112
+ "VALIDATION_ERROR"
113
+ );
114
+ }
115
+ if (options.template && options.content) {
116
+ throw new RelayError(
117
+ "Provide template or content, not both",
118
+ 400,
119
+ "VALIDATION_ERROR"
120
+ );
121
+ }
122
+ if (options.content) {
123
+ if (!options.content.subject) {
124
+ throw new RelayError(
125
+ "Email content requires a subject",
126
+ 400,
127
+ "VALIDATION_ERROR"
128
+ );
129
+ }
130
+ if (!options.content.html && !options.content.text) {
131
+ throw new RelayError(
132
+ "Email content requires either html or text",
133
+ 400,
134
+ "VALIDATION_ERROR"
135
+ );
136
+ }
137
+ }
138
+ const sourceAction = options.metadata?.sourceAction ?? options.template ?? "raw_email";
139
+ return this.send({
140
+ ...options.template ? { template: options.template } : {},
141
+ ...options.content ? { content: options.content } : {},
142
+ channel: "email",
143
+ recipient: options.to,
144
+ data: options.data ?? {},
145
+ options: options.options,
146
+ metadata: {
147
+ sourceService: options.metadata?.sourceService ?? this.config.sourceService,
148
+ sourceAction,
149
+ correlationId: options.metadata?.correlationId
150
+ }
151
+ });
152
+ }
153
+ /**
154
+ * Send an SMS through InsureRelay.
155
+ * Provide either `template` (with `data`) or `content` (raw text).
156
+ */
157
+ async sendSMS(options) {
158
+ if (!options.to.phone || !/^\+[1-9]\d{1,14}$/.test(options.to.phone)) {
159
+ throw new RelayError(
160
+ "Recipient phone must be in E.164 format (e.g., +15551234567)",
161
+ 400,
162
+ "VALIDATION_ERROR"
163
+ );
164
+ }
165
+ if (!options.template && !options.content) {
166
+ throw new RelayError(
167
+ "Either template or content is required",
168
+ 400,
169
+ "VALIDATION_ERROR"
170
+ );
171
+ }
172
+ if (options.template && options.content) {
173
+ throw new RelayError(
174
+ "Provide template or content, not both",
175
+ 400,
176
+ "VALIDATION_ERROR"
177
+ );
178
+ }
179
+ if (options.content && !options.content.text) {
180
+ throw new RelayError(
181
+ "SMS content requires text",
182
+ 400,
183
+ "VALIDATION_ERROR"
184
+ );
185
+ }
186
+ const sourceAction = options.metadata?.sourceAction ?? options.template ?? "raw_sms";
187
+ return this.send({
188
+ ...options.template ? { template: options.template } : {},
189
+ ...options.content ? { content: options.content } : {},
190
+ channel: "sms",
191
+ recipient: options.to,
192
+ data: options.data ?? {},
193
+ options: options.options,
194
+ metadata: {
195
+ sourceService: options.metadata?.sourceService ?? this.config.sourceService,
196
+ sourceAction,
197
+ correlationId: options.metadata?.correlationId
198
+ }
199
+ });
200
+ }
201
+ /**
202
+ * Get the delivery status of a sent message.
203
+ */
204
+ async getStatus(messageId) {
205
+ return this.request("GET", `/status/${messageId}`);
206
+ }
207
+ /**
208
+ * List available templates on the relay service.
209
+ */
210
+ async listTemplates() {
211
+ const response = await this.request("GET", "/templates");
212
+ return response.templates;
213
+ }
214
+ /**
215
+ * Low-level send — use sendEmail() or sendSMS() instead.
216
+ */
217
+ async send(body) {
218
+ return this.request("POST", "/send", body);
219
+ }
220
+ async request(method, path, body, attempt = 0) {
221
+ const { url, headers } = await this.buildRequest(path);
222
+ const fetchOptions = {
223
+ method,
224
+ headers: {
225
+ ...headers,
226
+ ...body ? { "Content-Type": "application/json" } : {}
227
+ },
228
+ ...body ? { body: JSON.stringify(body) } : {},
229
+ signal: AbortSignal.timeout(this.config.timeoutMs)
230
+ };
231
+ let response;
232
+ try {
233
+ response = await fetch(url, fetchOptions);
234
+ } catch (err) {
235
+ if (attempt < this.config.retries) {
236
+ const baseDelay = Math.min(1e3 * 2 ** attempt, 5e3);
237
+ const delay = baseDelay * (0.5 + Math.random() * 0.5);
238
+ await sleep(delay);
239
+ return this.request(method, path, body, attempt + 1);
240
+ }
241
+ const isTimeout = err instanceof DOMException && err.name === "TimeoutError";
242
+ const message = isTimeout ? `Request timed out after ${this.config.timeoutMs}ms` : err instanceof Error ? err.message : "Network error";
243
+ throw new RelayError(
244
+ `Failed to reach InsureRelay: ${message}`,
245
+ 0,
246
+ isTimeout ? "TIMEOUT" : "NETWORK_ERROR"
247
+ );
248
+ }
249
+ let json;
250
+ try {
251
+ json = await response.json();
252
+ } catch {
253
+ if (response.status >= 500 && attempt < this.config.retries) {
254
+ const baseDelay = Math.min(1e3 * 2 ** attempt, 5e3);
255
+ const delay = baseDelay * (0.5 + Math.random() * 0.5);
256
+ await sleep(delay);
257
+ return this.request(method, path, body, attempt + 1);
258
+ }
259
+ throw new RelayError(
260
+ `InsureRelay returned ${response.status} with non-JSON body`,
261
+ response.status,
262
+ "PARSE_ERROR"
263
+ );
264
+ }
265
+ if (!response.ok) {
266
+ if (response.status >= 500 && attempt < this.config.retries) {
267
+ const baseDelay = Math.min(1e3 * 2 ** attempt, 5e3);
268
+ const delay = baseDelay * (0.5 + Math.random() * 0.5);
269
+ await sleep(delay);
270
+ return this.request(method, path, body, attempt + 1);
271
+ }
272
+ throw new RelayError(
273
+ json.error ?? `InsureRelay returned ${response.status}`,
274
+ response.status,
275
+ json.error,
276
+ json.details
277
+ );
278
+ }
279
+ if (!json.data) {
280
+ throw new RelayError("InsureRelay returned success but no data", 500, "EMPTY_RESPONSE");
281
+ }
282
+ return json.data;
283
+ }
284
+ async buildRequest(path) {
285
+ const headers = {};
286
+ if (this.config.relayUrl) {
287
+ const url2 = `${this.config.relayUrl}${path}`;
288
+ if (this.config.accessTokenFn) {
289
+ headers["Authorization"] = `Bearer ${await this.config.accessTokenFn()}`;
290
+ } else if (this.config.internalKey) {
291
+ headers["X-Internal-Key"] = this.config.internalKey;
292
+ }
293
+ return { url: url2, headers };
294
+ }
295
+ const baseUrl = this.config.janusUrl ?? DEFAULT_JANUS_URL;
296
+ const url = `${baseUrl}${RELAY_API_PATH}${path}`;
297
+ headers["Authorization"] = `Bearer ${this.getToken()}`;
298
+ return { url, headers };
299
+ }
300
+ getToken() {
301
+ if (this.cachedToken && !isTokenExpired(this.cachedToken)) {
302
+ return this.cachedToken;
303
+ }
304
+ if (!this.config.jwtSecret || !this.config.programId) {
305
+ throw new RelayError(
306
+ "jwtSecret and programId are required for Janus auth",
307
+ 500,
308
+ "CONFIG_ERROR"
309
+ );
310
+ }
311
+ this.cachedToken = mintServiceJwt(
312
+ this.config.jwtSecret,
313
+ this.config.programId,
314
+ this.config.tokenTtlSeconds
315
+ );
316
+ return this.cachedToken;
317
+ }
318
+ };
319
+ function sleep(ms) {
320
+ return new Promise((resolve) => setTimeout(resolve, ms));
321
+ }
322
+ export {
323
+ RelayClient,
324
+ RelayError,
325
+ isTokenExpired,
326
+ mintServiceJwt
327
+ };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@insureco/relay",
3
+ "version": "0.3.1",
4
+ "description": "SDK for sending email and SMS through InsureRelay on the Tawa platform",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
22
+ "lint": "tsc --noEmit",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "keywords": [
26
+ "insureco",
27
+ "tawa",
28
+ "relay",
29
+ "email",
30
+ "sms",
31
+ "sendgrid",
32
+ "twilio"
33
+ ],
34
+ "author": "InsurEco",
35
+ "license": "MIT",
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^25.2.3",
41
+ "tsup": "^8.0.0",
42
+ "typescript": "^5.4.0",
43
+ "vitest": "^2.0.0"
44
+ },
45
+ "engines": {
46
+ "node": ">=18.0.0"
47
+ }
48
+ }