@siglo/notifications 0.0.0-stage → 1.0.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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +478 -2
  3. package/dist/approvals.d.ts +38 -0
  4. package/dist/approvals.d.ts.map +1 -0
  5. package/dist/approvals.js +111 -0
  6. package/dist/approvals.js.map +1 -0
  7. package/dist/callbacks.d.ts +14 -0
  8. package/dist/callbacks.d.ts.map +1 -0
  9. package/dist/callbacks.js +144 -0
  10. package/dist/callbacks.js.map +1 -0
  11. package/dist/dispatch.d.ts +26 -0
  12. package/dist/dispatch.d.ts.map +1 -0
  13. package/dist/dispatch.js +42 -0
  14. package/dist/dispatch.js.map +1 -0
  15. package/dist/egress.d.ts +8 -0
  16. package/dist/egress.d.ts.map +1 -0
  17. package/dist/egress.js +96 -0
  18. package/dist/egress.js.map +1 -0
  19. package/dist/index.d.ts +11 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +27 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/monitor.d.ts +10 -0
  24. package/dist/monitor.d.ts.map +1 -0
  25. package/dist/monitor.js +20 -0
  26. package/dist/monitor.js.map +1 -0
  27. package/dist/registry.d.ts +5 -0
  28. package/dist/registry.d.ts.map +1 -0
  29. package/dist/registry.js +15 -0
  30. package/dist/registry.js.map +1 -0
  31. package/dist/sms.d.ts +28 -0
  32. package/dist/sms.d.ts.map +1 -0
  33. package/dist/sms.js +43 -0
  34. package/dist/sms.js.map +1 -0
  35. package/dist/steps.d.ts +7 -0
  36. package/dist/steps.d.ts.map +1 -0
  37. package/dist/steps.js +131 -0
  38. package/dist/steps.js.map +1 -0
  39. package/dist/testing/index.d.ts +18 -0
  40. package/dist/testing/index.d.ts.map +1 -0
  41. package/dist/testing/index.js +19 -0
  42. package/dist/testing/index.js.map +1 -0
  43. package/dist/testing.d.ts +18 -0
  44. package/dist/testing.d.ts.map +1 -0
  45. package/dist/testing.js +19 -0
  46. package/dist/testing.js.map +1 -0
  47. package/dist/tokens.d.ts +24 -0
  48. package/dist/tokens.d.ts.map +1 -0
  49. package/dist/tokens.js +48 -0
  50. package/dist/tokens.js.map +1 -0
  51. package/dist/transports.d.ts +120 -0
  52. package/dist/transports.d.ts.map +1 -0
  53. package/dist/transports.js +172 -0
  54. package/dist/transports.js.map +1 -0
  55. package/dist/types.d.ts +106 -0
  56. package/dist/types.d.ts.map +1 -0
  57. package/dist/types.js +3 -0
  58. package/dist/types.js.map +1 -0
  59. package/package.json +64 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017 Jerico de guzman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,479 @@
1
- # Temporary Holding Version
1
+ # @siglo/notifications
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > Security migration: read [approval authorization, POST confirmation, durable recovery and delivery guarantees](../../docs/adapters/notification-hardening.md). GET is read-only; configure `authorize` or explicitly opt into `allowBearerApproval`.
4
+
5
+
6
+ Enterprise multi-channel notifications, tamper-proof cryptographic approval tokens, and durable human-in-the-loop callback resumption for Siglo workflows.
7
+
8
+ [![npm version](https://img.shields.io/npm/v/@siglo/notifications.svg)](https://www.npmjs.com/package/@siglo/notifications)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
10
+ [![Coverage: 100%](https://img.shields.io/badge/Coverage-100%25-brightgreen.svg)](https://github.com/jsoftgem/siglo)
11
+
12
+ ---
13
+
14
+ ## 1. Overview & Architecture
15
+
16
+ In enterprise backend workflows, human intervention (e.g. invoice approvals, production deployment gates, manual fraud reviews) and notifications across Slack, Email, Discord, and SMS are critical. `@siglo/notifications` integrates these patterns directly into Siglo's 5-phase step lifecycle:
17
+
18
+ ```mermaid
19
+ flowchart TD
20
+ subgraph Execution["1. Workflow Dispatch & Suspension"]
21
+ Step["sendInteractiveApprovalStep('managerApproval', ...)"]
22
+ EgressCheck["Assert Transport Host in ASL Network Policy"]
23
+ IdempCheck["Claim Idempotency Key (stackId:stepName:recipientHash)"]
24
+ TokenGen["Generate HMAC-SHA256 Signed Action URLs (KeyRing)"]
25
+ Dispatch["Dispatch via Provider (Slack Block Kit / HTML Email)"]
26
+ Wait["waitForEvent('APPROVAL_RESOLVED', { correlationKey, timeout })"]
27
+ Suspend["Checkpoint to PostgreSQL / Redis (Zero Memory Footprint)"]
28
+
29
+ Step --> EgressCheck --> IdempCheck --> TokenGen --> Dispatch --> Wait --> Suspend
30
+ end
31
+
32
+ subgraph Resumption["2. Webhook Callback & Distributed Resumption"]
33
+ User(["Human Approver clicks 'Approve' or 'Reject'"])
34
+ Router["Callback Router (@siglo/express, fastify, hono)"]
35
+ Verify["verifyActionToken() (timingSafeCompare, tolerance window)"]
36
+ RevokeCheck{"isRevoked(correlationKey)?"}
37
+ ClaimWait["Atomic Storage Claim: claimWaitRecord(correlationKey)"]
38
+ ResumeFlow["resumeWorkflow(flow, correlationKey, { approved: true })"]
39
+ Duplicate["Reject with 409 Conflict (WorkflowAlreadyResumedException)"]
40
+
41
+ User --> Router --> Verify --> RevokeCheck
42
+ RevokeCheck -- Yes (Cancelled) --> Duplicate
43
+ RevokeCheck -- No --> ClaimWait
44
+ ClaimWait -- Claim Won (200 OK) --> ResumeFlow
45
+ ClaimWait -- Already Claimed (409) --> Duplicate
46
+ end
47
+
48
+ subgraph Scheduler["3. Timeout Awakening (Dormant Resumption)"]
49
+ ExpiredWait["Approval Timeout Elapsed ('2h', '48h')"]
50
+ Poller["@siglo/scheduler (PostgresWaitPoller / session locks)"]
51
+ TimeoutResume["resumeWorkflow(..., { event: 'timer:expired', allowExpired: true })"]
52
+
53
+ ExpiredWait --> Poller --> TimeoutResume
54
+ end
55
+ ```
56
+
57
+ ---
58
+
59
+ ## 2. Installation & Requirements
60
+
61
+ Install `@siglo/notifications` alongside `@siglo/core`, `@siglo/crypto`, and `@siglo/http`:
62
+
63
+ ```bash
64
+ npm install @siglo/notifications @siglo/crypto @siglo/http @siglo/core
65
+ ```
66
+
67
+ Peer dependencies:
68
+ - `@siglo/core`: `^1.0.0`
69
+ - `@siglo/crypto`: `^1.0.0`
70
+ - `@siglo/http`: `^1.0.0`
71
+ - `@siglo/scheduler` (optional, for background timeout polling): `^1.0.0`
72
+ - `@siglo/postgres` (optional, for persistent checkpoints and shared revocation): `^1.0.0`
73
+
74
+ ---
75
+
76
+ ## 3. Quick Start: Interactive Human Approval Flow
77
+
78
+ ```typescript
79
+ import express from 'express';
80
+ import { flow, waitForEvent, Step, useStorage } from '@siglo/core';
81
+ import { PostgresStorageAdapter } from '@siglo/postgres';
82
+ import { StaticKeyRing } from '@siglo/crypto';
83
+ import {
84
+ registerNotificationTransport,
85
+ SlackTransport,
86
+ sendInteractiveApprovalStep,
87
+ createNotificationExpressMiddleware,
88
+ configureApprovalRevocationStore,
89
+ PostgresApprovalRevocationStore,
90
+ PostgresNotificationDispatchStore,
91
+ configureNotificationDispatchStore
92
+ } from '@siglo/notifications';
93
+ import { Pool } from 'pg';
94
+
95
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
96
+ const storage = new PostgresStorageAdapter({ client: pool });
97
+ await storage.ensureTables();
98
+ useStorage(storage);
99
+
100
+ // 1. Shared revocation store across workers
101
+ const revocationStore = new PostgresApprovalRevocationStore(pool);
102
+ await revocationStore.initialize();
103
+ configureApprovalRevocationStore(revocationStore);
104
+ const dispatchStore = new PostgresNotificationDispatchStore(pool);
105
+ await dispatchStore.initialize();
106
+ configureNotificationDispatchStore(dispatchStore);
107
+
108
+ // 2. Register transports globally (or pass per-step)
109
+ registerNotificationTransport(new SlackTransport({
110
+ apiKey: process.env.SLACK_BOT_TOKEN!
111
+ }));
112
+
113
+ // 3. Define KeyRing for HMAC signing and key rotation
114
+ const approvalKeyRing = new StaticKeyRing('primary', {
115
+ primary: process.env.APPROVAL_SECRET_CURRENT!,
116
+ previous: process.env.APPROVAL_SECRET_PREVIOUS!
117
+ });
118
+
119
+ // 4. Define the approval workflow
120
+ export const deploymentWorkflow = flow(
121
+ // Step 1: Send Slack Block Kit card with signed Approve/Reject buttons
122
+ sendInteractiveApprovalStep('requestApproval', {
123
+ channel: 'slack',
124
+ recipient: (ctx: { leadDevSlackId: string }) => ({ slackUserId: ctx.leadDevSlackId }),
125
+ title: (ctx: { service: string; version: string }) => `Deploy ${ctx.service}@${ctx.version}?`,
126
+ description: (ctx: { deploymentId: string }) => `Deployment ID: ${ctx.deploymentId}`,
127
+ correlationKey: (ctx: { deploymentId: string }) => `deploy:${ctx.deploymentId}`,
128
+ timeout: '2h',
129
+ onApproveEvent: 'APPROVAL_RESOLVED',
130
+ onRejectEvent: 'APPROVAL_RESOLVED',
131
+ callbackBaseUrl: 'https://orchestrator.acme.com/api/siglo/callback',
132
+ signingSecret: approvalKeyRing,
133
+ autoCompensate: true // Updates Slack card to 'Cancelled' on failure or rollback
134
+ }),
135
+
136
+ // Step 2: Suspends workflow into PostgreSQL durable checkpoint with a 2h timer
137
+ waitForEvent('APPROVAL_RESOLVED', {
138
+ correlationKey: (ctx: { deploymentId: string }) => `deploy:${ctx.deploymentId}`,
139
+ timeout: '2h'
140
+ }),
141
+
142
+ // Step 3: Guard on approved decision before executing side effects
143
+ new Step('verifyDecision', async (ctx: { approved?: boolean; event?: { type: string } }) => {
144
+ if (ctx.approved !== true || ctx.event?.type === 'timer:expired') {
145
+ throw new Error('Deployment approval was rejected or expired');
146
+ }
147
+ return { verified: true };
148
+ }),
149
+
150
+ deployServiceStep
151
+ );
152
+
153
+ // 5. Expose callback endpoint in Express
154
+ const app = express();
155
+ app.use(express.urlencoded({ extended: false }));
156
+ app.all(
157
+ '/api/siglo/callback',
158
+ createNotificationExpressMiddleware({
159
+ workflow: deploymentWorkflow,
160
+ signingSecret: approvalKeyRing,
161
+ authorize: authorizeApproval, // Application identity and CSRF check; see migration guide.
162
+ responseFormat: 'html',
163
+ allowedOrigins: ['https://orchestrator.acme.com', 'https://dashboard.acme.com']
164
+ })
165
+ );
166
+ app.listen(3000);
167
+ ```
168
+
169
+ ---
170
+
171
+ ## 4. Multi-Channel Transports
172
+
173
+ `@siglo/notifications` ships with built-in adapters that adhere to Siglo's AsyncLocalStorage (ASL) network egress perimeter and require zero third-party SDK dependencies (using native HTTPS with validated socket DNS resolution):
174
+
175
+ | Transport | Channel | Allowed Hosts | Key Features |
176
+ |---|---|---|---|
177
+ | `SlackTransport` | `slack` | `slack.com` | Block Kit action buttons, message cancellation via `chat.update`, modal forms via `openModal` |
178
+ | `ResendTransport` | `email` | `api.resend.com` | Responsive HTML email action templates |
179
+ | `SendGridTransport` | `email` | `api.sendgrid.com` | HTML email dispatch with `x-message-id` tracking |
180
+ | `PostmarkTransport` | `email` | `api.postmarkapp.com` | HTML + plain-text email with server token auth |
181
+ | `SmtpTransport` | `email` | User-defined host | Injected SMTP client (`sendMail`) without bundled dependencies |
182
+ | `DiscordTransport` | `discord` | `discord.com` | Webhook embeds with clickable action links |
183
+ | `TwilioTransport` | `sms` | `api.twilio.com` | Basic SMS dispatch and two-way signed webhook parsing |
184
+ | `PushWebhookTransport` | `push` | Webhook hostname | Provider-neutral JSON push payloads over HTTPS |
185
+
186
+ ### Transport Configuration Examples
187
+
188
+ ```typescript
189
+ import {
190
+ SlackTransport, ResendTransport, SendGridTransport, PostmarkTransport,
191
+ DiscordTransport, TwilioTransport, SmtpTransport, PushWebhookTransport,
192
+ registerNotificationTransport
193
+ } from '@siglo/notifications';
194
+
195
+ // Slack
196
+ registerNotificationTransport(new SlackTransport({
197
+ apiKey: process.env.SLACK_BOT_TOKEN!
198
+ }));
199
+
200
+ // Resend Email
201
+ registerNotificationTransport(new ResendTransport({
202
+ apiKey: process.env.RESEND_API_KEY!,
203
+ from: 'ops@acme.com'
204
+ }));
205
+
206
+ // SendGrid Email
207
+ registerNotificationTransport(new SendGridTransport({
208
+ apiKey: process.env.SENDGRID_API_KEY!,
209
+ from: 'ops@acme.com'
210
+ }));
211
+
212
+ // Postmark Email
213
+ registerNotificationTransport(new PostmarkTransport({
214
+ apiKey: process.env.POSTMARK_SERVER_TOKEN!,
215
+ from: 'ops@acme.com'
216
+ }));
217
+
218
+ // Discord
219
+ registerNotificationTransport(new DiscordTransport());
220
+
221
+ // Twilio SMS
222
+ registerNotificationTransport(new TwilioTransport({
223
+ accountSid: process.env.TWILIO_ACCOUNT_SID!,
224
+ authToken: process.env.TWILIO_AUTH_TOKEN!,
225
+ from: '+15551234567'
226
+ }));
227
+
228
+ // Generic SMTP (e.g. Nodemailer)
229
+ registerNotificationTransport(new SmtpTransport({
230
+ host: 'smtp.mailgun.org',
231
+ from: 'no-reply@acme.com',
232
+ sendMail: async (mail) => nodemailerTransport.sendMail(mail)
233
+ }));
234
+
235
+ // Generic HTTPS Push Webhook
236
+ registerNotificationTransport(new PushWebhookTransport({
237
+ url: 'https://push.acme.com/v1/notify',
238
+ headers: { 'X-Service-Token': process.env.PUSH_SERVICE_TOKEN! }
239
+ }));
240
+ ```
241
+
242
+ ---
243
+
244
+ ## 5. Workflow Step API
245
+
246
+ ### `sendNotificationStep(name, options)`
247
+
248
+ Dispatches one-way alerts (email, chat, SMS, push) with automatic idempotency deduplication and context redaction:
249
+
250
+ ```typescript
251
+ import { sendNotificationStep } from '@siglo/notifications';
252
+
253
+ const alertOps = sendNotificationStep('alertOps', {
254
+ channel: 'slack',
255
+ recipient: { slackChannelId: 'C_OPS_ALERTS' },
256
+ template: (ctx) => `High latency detected on ${ctx.serviceName}: ${ctx.p99}ms`,
257
+ deduplicationTtl: 3600 // Suppresses duplicate alerts within 1 hour
258
+ });
259
+ ```
260
+
261
+ ### `sendInteractiveApprovalStep(name, options)`
262
+
263
+ Generates signed action tokens, formats interactive cards/emails, registers compensation rollbacks, and attaches approval metadata:
264
+
265
+ ```typescript
266
+ import { sendInteractiveApprovalStep } from '@siglo/notifications';
267
+
268
+ const approvalStep = sendInteractiveApprovalStep('legalApproval', {
269
+ channel: 'email',
270
+ recipient: (ctx) => ({ email: ctx.legalReviewerEmail }),
271
+ title: (ctx) => `Contract Review Required: ${ctx.contractTitle}`,
272
+ description: (ctx) => `Value: $${ctx.contractValue}`,
273
+ correlationKey: (ctx) => `contract:${ctx.contractId}`,
274
+ timeout: '72h',
275
+ onApproveEvent: 'CONTRACT_APPROVED',
276
+ onRejectEvent: 'CONTRACT_REJECTED',
277
+ callbackBaseUrl: 'https://api.acme.com/siglo/callback',
278
+ signingSecret: process.env.APPROVAL_SECRET!,
279
+ autoCompensate: true
280
+ });
281
+ ```
282
+
283
+ ---
284
+
285
+ ## 6. Cryptographic Action Tokens & Secret Rotation
286
+
287
+ Action tokens embed callback metadata in a compact URL-safe format:
288
+
289
+ $$\text{Token} = \text{base64url}(\text{Payload}) \,\|\, \text{base64url}(\text{HMAC-SHA256}(\text{Payload}, \text{Secret}))$$
290
+
291
+ ### Features:
292
+ - **No Database Lookup on Verification**: Validates authenticity and expiration instantly before accessing storage.
293
+ - **Constant-Time Verification**: Uses `@siglo/crypto`'s `timingSafeCompare` to defeat timing attacks.
294
+ - **Replay handling**: Durable claims reject completed requests. `toleranceSeconds` checks future issuance only; nonces are not independently consumed.
295
+ - **Key Rotation**: Compatible with `@siglo/crypto`'s `StaticKeyRing` to verify tokens signed with previous keys during rotation windows.
296
+
297
+ ```typescript
298
+ import { StaticKeyRing } from '@siglo/crypto';
299
+ import { signActionToken, verifyActionToken } from '@siglo/notifications';
300
+
301
+ const keyRing = new StaticKeyRing('v2', {
302
+ v2: 'current-secret-key-2026',
303
+ v1: 'legacy-secret-key-2025'
304
+ });
305
+
306
+ // Verify token
307
+ const payload = await verifyActionToken(tokenString, keyRing, {
308
+ toleranceSeconds: 60 // Allow 60s future clock skew
309
+ });
310
+ ```
311
+
312
+ ---
313
+
314
+ ## 7. Web Framework Resumption Handlers
315
+
316
+ First-class route handlers are provided for `@siglo/express`, `@siglo/fastify`, and `@siglo/hono` with built-in security headers, CORS preflight handling, and response customization:
317
+
318
+ ### Supported Security Headers
319
+ - `Cache-Control: no-store`
320
+ - `Referrer-Policy: no-referrer`
321
+ - `X-Content-Type-Options: nosniff`
322
+ - `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'`
323
+
324
+ ### Express Example
325
+
326
+ ```typescript
327
+ import express from 'express';
328
+ import { createNotificationExpressMiddleware } from '@siglo/notifications';
329
+
330
+ const app = express();
331
+ app.use(express.urlencoded({ extended: false }));
332
+
333
+ app.all(
334
+ '/api/siglo/callback',
335
+ createNotificationExpressMiddleware({
336
+ workflow: orderFlow,
337
+ signingSecret: process.env.APPROVAL_SECRET!,
338
+ authorize: authorizeApproval, // Application identity and CSRF check; see migration guide.
339
+ responseFormat: 'html', // Renders accessible confirmation page
340
+ allowedOrigins: ['https://orchestrator.acme.com', 'https://dashboard.acme.com'],
341
+ onSuccessRedirect: 'https://dashboard.acme.com/approvals/success',
342
+ onExpiredRedirect: 'https://dashboard.acme.com/approvals/expired',
343
+ onErrorRedirect: 'https://dashboard.acme.com/approvals/error'
344
+ })
345
+ );
346
+ ```
347
+
348
+ ### Fastify Example
349
+
350
+ ```typescript
351
+ import Fastify from 'fastify';
352
+ import { createNotificationFastifyHandler } from '@siglo/notifications';
353
+
354
+ const fastify = Fastify();
355
+ const handler = createNotificationFastifyHandler({
356
+ workflow: orderFlow,
357
+ signingSecret: process.env.APPROVAL_SECRET!,
358
+ authorize: authorizeApproval // Application authentication and CSRF check.
359
+ });
360
+ fastify.route({ method: ['GET', 'POST'], url: '/api/siglo/callback', handler });
361
+ ```
362
+
363
+ ### Hono Example (Node)
364
+
365
+ ```typescript
366
+ import { Hono } from 'hono';
367
+ import { createNotificationHonoHandler } from '@siglo/notifications';
368
+
369
+ const app = new Hono();
370
+ app.all('/api/siglo/callback', createNotificationHonoHandler({
371
+ workflow: orderFlow,
372
+ signingSecret: process.env.APPROVAL_SECRET!,
373
+ authorize: authorizeApproval
374
+ }));
375
+ ```
376
+
377
+ ---
378
+
379
+ ## 8. Inbound Twilio SMS Two-Way Approvals
380
+
381
+ Parse and cryptographically verify inbound SMS approval webhooks (`APPROVE <ref>` or `REJECT <ref>`) adhering to Twilio's signed form specification:
382
+
383
+ ```typescript
384
+ import { parseTwilioSmsReply } from '@siglo/notifications';
385
+
386
+ app.post('/api/twilio/sms', express.urlencoded({ extended: false }), async (req, res) => {
387
+ const reply = parseTwilioSmsReply(req.body, {
388
+ publicUrl: 'https://api.acme.com/api/twilio/sms',
389
+ authToken: process.env.TWILIO_AUTH_TOKEN!,
390
+ signature: req.headers['x-twilio-signature'] as string
391
+ });
392
+
393
+ // reply: { approved: true, reference: 'deploy:123', from: '+15550001', messageId: 'SM...' }
394
+ // Validate a persisted, unpredictable challenge and expected sender first.
395
+ // Atomically consume the pending request and deduplicate MessageSid in your
396
+ // application approval service. See the security migration guide.
397
+ await approvalService.acceptVerifiedSmsReply(reply);
398
+
399
+ res.type('text/xml').send('<Response></Response>');
400
+ });
401
+ ```
402
+
403
+ ---
404
+
405
+ ## 9. Saga Compensation & Shared Revocations
406
+
407
+ When a downstream step fails or a workflow times out, Siglo triggers Named Saga Compensation (`cancelNotification`):
408
+ 1. **Chat Channels (Slack)**: Calls `chat.update` to replace interactive buttons with an alert (e.g. `"Workflow Cancelled"`).
409
+ 2. **Email & SMS**: Records the `correlationKey` into the revocation store so any future clicks on existing email links return `409 Conflict`.
410
+
411
+ ### Configuring Shared PostgreSQL Revocation Store
412
+
413
+ ```typescript
414
+ import { configureApprovalRevocationStore, PostgresApprovalRevocationStore } from '@siglo/notifications';
415
+
416
+ const revocationStore = new PostgresApprovalRevocationStore(pgPool);
417
+ await revocationStore.initialize();
418
+ configureApprovalRevocationStore(revocationStore);
419
+ ```
420
+
421
+ ---
422
+
423
+ ## 10. Observability & PII Redaction
424
+
425
+ Standard recipient identifiers, credentials, and tokens are automatically redacted by Siglo core before telemetry dispatch. For fine-grained domain PII masking, attach a `NotificationMonitor`:
426
+
427
+ ```typescript
428
+ import { NotificationMonitor } from '@siglo/notifications';
429
+
430
+ const monitor = new NotificationMonitor([
431
+ 'outputSummary.customers.*.contact',
432
+ 'order.shippingAddress.phone'
433
+ ]);
434
+
435
+ workflow.execute(input, { monitor });
436
+ ```
437
+
438
+ ---
439
+
440
+ ## 11. Testing & Verification
441
+
442
+ `@siglo/notifications` ships with a zero-network mock transport in `@siglo/notifications/testing`:
443
+
444
+ ```typescript
445
+ import { MockNotificationTransport } from '@siglo/notifications/testing';
446
+ import { sendNotificationStep } from '@siglo/notifications';
447
+
448
+ const mock = new MockNotificationTransport();
449
+ const step = sendNotificationStep('notify', {
450
+ channel: 'email',
451
+ recipient: 'user@example.com',
452
+ template: 'Hello world',
453
+ transport: mock
454
+ });
455
+
456
+ await step.execute({});
457
+ console.log(mock.messages.length); // 1
458
+ console.log(mock.messages[0].to); // 'user@example.com'
459
+ ```
460
+
461
+ ### Running Test Suites
462
+
463
+ ```bash
464
+ # Run unit suite (zero network egress)
465
+ npm test --workspace=@siglo/notifications
466
+
467
+ # Run 100% coverage verification
468
+ npm run test:coverage --workspace=@siglo/notifications
469
+
470
+ # Run multi-worker PostgreSQL integration suite
471
+ SIGLO_TEST_POSTGRES_URL=postgres://siglo:siglo_test@localhost:5432/siglo_notifications_test \
472
+ npm run test:postgres --workspace=@siglo/notifications
473
+ ```
474
+
475
+ ---
476
+
477
+ ## 12. License
478
+
479
+ MIT © [Jerico de Guzman](https://github.com/jsoftgem/siglo)
@@ -0,0 +1,38 @@
1
+ /** Acceptance and revocation must use the same lock implementation on every worker. */
2
+ export interface ApprovalRevocationStore {
3
+ revoke(correlationKey: string): Promise<void>;
4
+ isRevoked(correlationKey: string): Promise<boolean>;
5
+ withApprovalLock<T>(correlationKey: string, action: () => Promise<T>): Promise<T>;
6
+ }
7
+ export declare class InMemoryApprovalRevocationStore implements ApprovalRevocationStore {
8
+ private revoked;
9
+ private locks;
10
+ private scope;
11
+ withApprovalLock<T>(key: string, action: () => Promise<T>): Promise<T>;
12
+ revoke(key: string): Promise<void>;
13
+ isRevoked(key: string): Promise<boolean>;
14
+ }
15
+ export declare function configureApprovalRevocationStore(value: ApprovalRevocationStore): void;
16
+ export declare function getApprovalRevocationStore(): ApprovalRevocationStore;
17
+ interface Connection {
18
+ query(sql: string, values?: any[]): Promise<{
19
+ rows: any[];
20
+ }>;
21
+ release?(error?: Error): void;
22
+ }
23
+ interface Client extends Connection {
24
+ connect?(): Promise<Connection | void>;
25
+ }
26
+ /** Use a Pool in multi-worker deployments; locks hold a dedicated connection until completion. */
27
+ export declare class PostgresApprovalRevocationStore implements ApprovalRevocationStore {
28
+ private client;
29
+ private scope;
30
+ private directClientBusy;
31
+ constructor(client: Client);
32
+ initialize(): Promise<void>;
33
+ withApprovalLock<T>(key: string, action: () => Promise<T>): Promise<T>;
34
+ revoke(correlationKey: string): Promise<void>;
35
+ isRevoked(correlationKey: string): Promise<boolean>;
36
+ }
37
+ export {};
38
+ //# sourceMappingURL=approvals.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"approvals.d.ts","sourceRoot":"","sources":["../src/approvals.ts"],"names":[],"mappings":"AAEA,uFAAuF;AACvF,MAAM,WAAW,uBAAuB;IACtC,MAAM,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C,SAAS,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACpD,gBAAgB,CAAC,CAAC,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;CACnF;AACD,qBAAa,+BAAgC,YAAW,uBAAuB;IAC7E,OAAO,CAAC,OAAO,CAAqB;IACpC,OAAO,CAAC,KAAK,CAA6B;IAC1C,OAAO,CAAC,KAAK,CAAgD;IACvD,gBAAgB,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAQtE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAClC,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAC/C;AAED,wBAAgB,gCAAgC,CAAC,KAAK,EAAE,uBAAuB,GAAG,IAAI,CAAmB;AACzG,wBAAgB,0BAA0B,IAAI,uBAAuB,CAAkB;AACvF,UAAU,UAAU;IAClB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,EAAE,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,EAAE,CAAA;KAAE,CAAC,CAAC;IAC7D,OAAO,CAAC,CAAC,KAAK,CAAC,EAAE,KAAK,GAAG,IAAI,CAAC;CAC/B;AACD,UAAU,MAAO,SAAQ,UAAU;IAAG,OAAO,CAAC,IAAI,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAA;CAAE;AAC9E,kGAAkG;AAClG,qBAAa,+BAAgC,YAAW,uBAAuB;IAGjE,OAAO,CAAC,MAAM;IAF1B,OAAO,CAAC,KAAK,CAAiF;IAC9F,OAAO,CAAC,gBAAgB,CAAS;gBACb,MAAM,EAAE,MAAM;IAC5B,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAG3B,gBAAgB,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAsBtE,MAAM,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAa7C,SAAS,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAI1D"}
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PostgresApprovalRevocationStore = exports.InMemoryApprovalRevocationStore = void 0;
4
+ exports.configureApprovalRevocationStore = configureApprovalRevocationStore;
5
+ exports.getApprovalRevocationStore = getApprovalRevocationStore;
6
+ const node_async_hooks_1 = require("node:async_hooks");
7
+ const core_1 = require("@siglo/core");
8
+ class InMemoryApprovalRevocationStore {
9
+ revoked = new Set();
10
+ locks = new Map();
11
+ scope = new node_async_hooks_1.AsyncLocalStorage();
12
+ async withApprovalLock(key, action) {
13
+ if (this.locks.has(key) && this.scope.getStore()?.get(key) === this.locks.get(key))
14
+ return action();
15
+ if (this.locks.has(key))
16
+ throw new core_1.DuplicateEventException(key);
17
+ const owner = Symbol(key);
18
+ this.locks.set(key, owner);
19
+ try {
20
+ return await this.scope.run(new Map([...(this.scope.getStore() ?? []), [key, owner]]), action);
21
+ }
22
+ finally {
23
+ this.locks.delete(key);
24
+ }
25
+ }
26
+ async revoke(key) { await this.withApprovalLock(key, async () => { this.revoked.add(key); const waiting = core_1.defaultDurableManager.getWaiting(key); if (waiting)
27
+ waiting.status = 'failed'; }); }
28
+ async isRevoked(key) { return this.revoked.has(key); }
29
+ }
30
+ exports.InMemoryApprovalRevocationStore = InMemoryApprovalRevocationStore;
31
+ let store = new InMemoryApprovalRevocationStore();
32
+ function configureApprovalRevocationStore(value) { store = value; }
33
+ function getApprovalRevocationStore() { return store; }
34
+ /** Use a Pool in multi-worker deployments; locks hold a dedicated connection until completion. */
35
+ class PostgresApprovalRevocationStore {
36
+ client;
37
+ scope = new node_async_hooks_1.AsyncLocalStorage();
38
+ directClientBusy = false;
39
+ constructor(client) {
40
+ this.client = client;
41
+ }
42
+ async initialize() {
43
+ await this.client.query('CREATE TABLE IF NOT EXISTS siglo_approval_revocations (correlation_key TEXT PRIMARY KEY, revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW())');
44
+ }
45
+ async withApprovalLock(key, action) {
46
+ if (this.scope.getStore()?.active && this.scope.getStore()?.key === key)
47
+ return action();
48
+ // pg.Client.connect() is not a pool checkout. Pools expose totalCount.
49
+ const pooled = 'totalCount' in this.client;
50
+ if (!pooled && this.directClientBusy)
51
+ throw new core_1.DuplicateEventException(key);
52
+ if (!pooled)
53
+ this.directClientBusy = true;
54
+ let connection;
55
+ let locked = false;
56
+ try {
57
+ connection = pooled ? await this.client.connect() : this.client;
58
+ const result = await connection.query('SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS locked', [`siglo:approval:${key}`]);
59
+ locked = result.rows[0]?.locked === true;
60
+ if (!locked)
61
+ throw new core_1.DuplicateEventException(key);
62
+ const scope = { key, client: connection, active: true };
63
+ try {
64
+ return await this.scope.run(scope, action);
65
+ }
66
+ finally {
67
+ scope.active = false;
68
+ }
69
+ }
70
+ finally {
71
+ try {
72
+ if (locked)
73
+ await connection.query('SELECT pg_advisory_unlock(hashtextextended($1, 0))', [`siglo:approval:${key}`]);
74
+ }
75
+ catch (error) {
76
+ if (pooled) {
77
+ connection?.release?.(error);
78
+ connection = undefined;
79
+ }
80
+ throw error;
81
+ }
82
+ finally {
83
+ if (pooled)
84
+ connection?.release?.();
85
+ else
86
+ this.directClientBusy = false;
87
+ }
88
+ }
89
+ }
90
+ async revoke(correlationKey) {
91
+ await this.withApprovalLock(correlationKey, async () => {
92
+ const adapter = (0, core_1.getStorageAdapter)();
93
+ const record = await adapter?.getWaitRecord?.(correlationKey);
94
+ const cancel = async () => {
95
+ // Cancel recovery first: a crash before the revocation INSERT must fail closed.
96
+ await adapter?.cancelWaitRecord?.(correlationKey);
97
+ await this.scope.getStore().client.query('INSERT INTO siglo_approval_revocations (correlation_key) VALUES ($1) ON CONFLICT (correlation_key) DO NOTHING', [correlationKey]);
98
+ };
99
+ if (record && adapter?.withWorkflowLock)
100
+ await adapter.withWorkflowLock(record.stackId, cancel);
101
+ else
102
+ await cancel();
103
+ });
104
+ }
105
+ async isRevoked(correlationKey) {
106
+ const result = await (this.scope.getStore()?.active ? this.scope.getStore().client : this.client).query('SELECT correlation_key FROM siglo_approval_revocations WHERE correlation_key = $1', [correlationKey]);
107
+ return result.rows.length > 0;
108
+ }
109
+ }
110
+ exports.PostgresApprovalRevocationStore = PostgresApprovalRevocationStore;
111
+ //# sourceMappingURL=approvals.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"approvals.js","sourceRoot":"","sources":["../src/approvals.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ import { CallbackHandlerOptions } from './types';
2
+ export interface NotificationCallbackResponse {
3
+ status: number;
4
+ body: {
5
+ message: string;
6
+ };
7
+ redirect?: string;
8
+ }
9
+ export declare function handleNotificationCallback(token: string, options: CallbackHandlerOptions): Promise<NotificationCallbackResponse>;
10
+ export declare function renderApprovalConfirmation(result: NotificationCallbackResponse): string;
11
+ export declare function createNotificationExpressMiddleware(options: CallbackHandlerOptions): (req: any, res: any) => Promise<void>;
12
+ export declare function createNotificationFastifyHandler(options: CallbackHandlerOptions): (req: any, reply: any) => Promise<void>;
13
+ export declare function createNotificationHonoHandler(options: CallbackHandlerOptions): (c: any) => Promise<Response>;
14
+ //# sourceMappingURL=callbacks.d.ts.map