@hookflo/tern 4.1.1 → 4.2.0

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 (37) hide show
  1. package/README.md +256 -438
  2. package/dist/adapters/cloudflare.d.ts +3 -0
  3. package/dist/adapters/cloudflare.js +25 -1
  4. package/dist/adapters/express.d.ts +3 -0
  5. package/dist/adapters/express.js +16 -0
  6. package/dist/adapters/nextjs.d.ts +3 -0
  7. package/dist/adapters/nextjs.js +25 -1
  8. package/dist/alerts.d.ts +7 -0
  9. package/dist/alerts.js +21 -0
  10. package/dist/index.d.ts +4 -0
  11. package/dist/index.js +20 -1
  12. package/dist/notifications/channels/discord/build-payload.d.ts +17 -0
  13. package/dist/notifications/channels/discord/build-payload.js +53 -0
  14. package/dist/notifications/channels/discord/index.d.ts +1 -0
  15. package/dist/notifications/channels/discord/index.js +5 -0
  16. package/dist/notifications/channels/slack/build-payload.d.ts +5 -0
  17. package/dist/notifications/channels/slack/build-payload.js +82 -0
  18. package/dist/notifications/channels/slack/index.d.ts +1 -0
  19. package/dist/notifications/channels/slack/index.js +5 -0
  20. package/dist/notifications/constants.d.ts +8 -0
  21. package/dist/notifications/constants.js +15 -0
  22. package/dist/notifications/dispatch.d.ts +8 -0
  23. package/dist/notifications/dispatch.js +13 -0
  24. package/dist/notifications/index.d.ts +10 -0
  25. package/dist/notifications/index.js +27 -0
  26. package/dist/notifications/send-alert.d.ts +2 -0
  27. package/dist/notifications/send-alert.js +69 -0
  28. package/dist/notifications/types.d.ts +48 -0
  29. package/dist/notifications/types.js +2 -0
  30. package/dist/notifications/utils.d.ts +4 -0
  31. package/dist/notifications/utils.js +80 -0
  32. package/dist/upstash/controls.d.ts +2 -6
  33. package/dist/upstash/controls.js +63 -0
  34. package/dist/upstash/index.d.ts +1 -1
  35. package/dist/upstash/queue.js +5 -1
  36. package/dist/upstash/types.d.ts +18 -0
  37. package/package.json +1 -1
@@ -0,0 +1,48 @@
1
+ export type AlertSeverity = 'info' | 'warning' | 'error' | 'critical';
2
+ export type AlertChannel = 'slack' | 'discord';
3
+ export interface AlertChannelConfig {
4
+ webhookUrl: string;
5
+ enabled?: boolean;
6
+ }
7
+ export interface AlertConfig {
8
+ slack?: AlertChannelConfig;
9
+ discord?: AlertChannelConfig;
10
+ }
11
+ export interface SendAlertOptions {
12
+ dlq?: boolean;
13
+ dlqId?: string;
14
+ eventId?: string;
15
+ source?: string;
16
+ title?: string;
17
+ message?: string;
18
+ severity?: AlertSeverity;
19
+ replayUrl?: string;
20
+ replayLabel?: string;
21
+ metadata?: Record<string, unknown>;
22
+ branding?: boolean;
23
+ }
24
+ export interface SendAlertResult {
25
+ channel: AlertChannel;
26
+ webhookUrl: string;
27
+ ok: boolean;
28
+ status?: number;
29
+ error?: string;
30
+ }
31
+ export interface SendAlertSummary {
32
+ success: boolean;
33
+ total: number;
34
+ delivered: number;
35
+ results: SendAlertResult[];
36
+ }
37
+ export interface AlertDestination {
38
+ channel: AlertChannel;
39
+ webhookUrl: string;
40
+ }
41
+ export interface AlertPayloadBuilderInput extends SendAlertOptions {
42
+ title: string;
43
+ message: string;
44
+ severity: AlertSeverity;
45
+ replayLabel: string;
46
+ eventId?: string;
47
+ metadata?: Record<string, unknown>;
48
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,4 @@
1
+ import type { AlertConfig, AlertDestination, AlertPayloadBuilderInput, SendAlertOptions } from './types';
2
+ export declare function compactMetadata(metadata?: Record<string, unknown>): string | undefined;
3
+ export declare function resolveDestinations(config: AlertConfig): AlertDestination[];
4
+ export declare function normalizeAlertOptions(options: SendAlertOptions): AlertPayloadBuilderInput;
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.compactMetadata = compactMetadata;
4
+ exports.resolveDestinations = resolveDestinations;
5
+ exports.normalizeAlertOptions = normalizeAlertOptions;
6
+ const constants_1 = require("./constants");
7
+ function compactMetadata(metadata) {
8
+ if (!metadata || Object.keys(metadata).length === 0)
9
+ return undefined;
10
+ const entries = Object.entries(metadata).slice(0, 8);
11
+ return entries.map(([key, value]) => `${key}: ${String(value)}`).join('\n');
12
+ }
13
+ function asNonEmptyString(value) {
14
+ return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
15
+ }
16
+ function asObject(value) {
17
+ return value && typeof value === 'object' ? value : undefined;
18
+ }
19
+ function pickNonEmptyString(...values) {
20
+ for (const value of values) {
21
+ const normalized = asNonEmptyString(value);
22
+ if (normalized)
23
+ return normalized;
24
+ }
25
+ return undefined;
26
+ }
27
+ function resolveSourceFromObject(value) {
28
+ const object = asObject(value);
29
+ if (!object)
30
+ return undefined;
31
+ return pickNonEmptyString(object.platform, object.provider, object.source, object.service, object.origin, object.type);
32
+ }
33
+ function resolveEventIdFromObject(value) {
34
+ const object = asObject(value);
35
+ if (!object)
36
+ return undefined;
37
+ return pickNonEmptyString(object.eventId, object.event_id, object.id, object.request_id, object.webhook_id, object.messageId, object.message_id);
38
+ }
39
+ function resolveSource(options) {
40
+ const metadata = options.metadata || {};
41
+ const metadataPayload = asObject(metadata.payload);
42
+ const metadataEvent = asObject(metadata.event);
43
+ const metadataData = asObject(metadata.data);
44
+ const metadataBody = asObject(metadata.body);
45
+ return pickNonEmptyString(options.source, metadata.source, metadata.platform, metadata.provider, metadata.eventSource, resolveSourceFromObject(metadataPayload), resolveSourceFromObject(metadataEvent), resolveSourceFromObject(metadataData), resolveSourceFromObject(metadataBody));
46
+ }
47
+ function resolveEventId(options) {
48
+ const metadata = options.metadata || {};
49
+ const metadataPayload = asObject(metadata.payload);
50
+ const metadataEvent = asObject(metadata.event);
51
+ const metadataData = asObject(metadata.data);
52
+ return pickNonEmptyString(options.eventId, options.dlqId, metadata.eventId, metadata.event_id, metadata.messageId, metadata.message_id, metadata.webhookId, metadata.webhook_id, metadata.id, resolveEventIdFromObject(metadataPayload), resolveEventIdFromObject(metadataEvent), resolveEventIdFromObject(metadataData));
53
+ }
54
+ function resolveDestinations(config) {
55
+ const destinations = [];
56
+ if (config.slack?.enabled !== false && config.slack?.webhookUrl) {
57
+ destinations.push({ channel: 'slack', webhookUrl: config.slack.webhookUrl });
58
+ }
59
+ if (config.discord?.enabled !== false && config.discord?.webhookUrl) {
60
+ destinations.push({ channel: 'discord', webhookUrl: config.discord.webhookUrl });
61
+ }
62
+ return destinations;
63
+ }
64
+ function normalizeAlertOptions(options) {
65
+ const isDlq = options.dlq === true;
66
+ const severity = options.severity || (isDlq ? 'error' : 'info');
67
+ const title = options.title || (isDlq ? constants_1.DEFAULT_DLQ_TITLE : constants_1.DEFAULT_ALERT_TITLE);
68
+ const message = options.message || (isDlq ? constants_1.DEFAULT_DLQ_MESSAGE : constants_1.DEFAULT_ALERT_MESSAGE);
69
+ const replayLabel = options.replayLabel || constants_1.DEFAULT_REPLAY_LABEL;
70
+ return {
71
+ ...options,
72
+ dlq: isDlq,
73
+ source: resolveSource(options) || 'unknown',
74
+ title,
75
+ message,
76
+ severity,
77
+ replayLabel,
78
+ eventId: resolveEventId(options),
79
+ };
80
+ }
@@ -1,6 +1,2 @@
1
- import { DLQMessage, EventFilter, ReplayResult, TernControlsConfig, TernEvent } from './types';
2
- export declare function createTernControls(config: TernControlsConfig): {
3
- dlq(): Promise<DLQMessage[]>;
4
- replay(dlqId: string): Promise<ReplayResult>;
5
- events(filter?: EventFilter): Promise<TernEvent[]>;
6
- };
1
+ import { TernControls, TernControlsConfig } from './types';
2
+ export declare function createTernControls(config: TernControlsConfig): TernControls;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createTernControls = createTernControls;
4
+ const send_alert_1 = require("../notifications/send-alert");
4
5
  const QSTASH_API_BASE = 'https://qstash.upstash.io/v2';
5
6
  function parseBody(body) {
6
7
  if (!body)
@@ -51,6 +52,9 @@ function deriveStatus(value) {
51
52
  return 'failed';
52
53
  return 'retrying';
53
54
  }
55
+ function withoutUndefined(value) {
56
+ return Object.fromEntries(Object.entries(value).filter(([, entryValue]) => entryValue !== undefined));
57
+ }
54
58
  function createTernControls(config) {
55
59
  return {
56
60
  async dlq() {
@@ -114,5 +118,64 @@ function createTernControls(config) {
114
118
  : mapped;
115
119
  return statusFiltered.slice(0, filter.limit ?? 20);
116
120
  },
121
+ async alert(options = {}) {
122
+ let replayMeta = {};
123
+ let resolvedSource = options.source;
124
+ let resolvedEventId = options.eventId;
125
+ if (options.dlq && (!resolvedSource || !resolvedEventId)) {
126
+ try {
127
+ const dlqMessages = await this.dlq();
128
+ const matchingMessage = dlqMessages.find((message) => message.dlqId === options.dlqId);
129
+ resolvedSource = resolvedSource || matchingMessage?.platform;
130
+ resolvedEventId = resolvedEventId || matchingMessage?.id;
131
+ }
132
+ catch (error) {
133
+ replayMeta = {
134
+ ...replayMeta,
135
+ dlqLookupError: error.message,
136
+ };
137
+ }
138
+ }
139
+ if (options.dlq) {
140
+ if (!options.dlqId || options.dlqId.trim() === '') {
141
+ throw new Error('[tern] controls.alert() with dlq: true requires dlqId.');
142
+ }
143
+ try {
144
+ const replay = await this.replay(options.dlqId);
145
+ replayMeta = {
146
+ replayAttempted: true,
147
+ replaySuccess: replay.success,
148
+ replayedAt: replay.replayedAt,
149
+ replayDlqId: options.dlqId,
150
+ };
151
+ }
152
+ catch (error) {
153
+ replayMeta = {
154
+ replayAttempted: true,
155
+ replaySuccess: false,
156
+ replayDlqId: options.dlqId,
157
+ replayError: error.message,
158
+ };
159
+ }
160
+ }
161
+ return (0, send_alert_1.sendAlert)({
162
+ slack: config.notifications?.slackWebhookUrl
163
+ ? { webhookUrl: config.notifications.slackWebhookUrl }
164
+ : undefined,
165
+ discord: config.notifications?.discordWebhookUrl
166
+ ? { webhookUrl: config.notifications.discordWebhookUrl }
167
+ : undefined,
168
+ }, {
169
+ ...options,
170
+ source: resolvedSource,
171
+ eventId: resolvedEventId || options.dlqId,
172
+ metadata: withoutUndefined({
173
+ ...(options.metadata || {}),
174
+ source: resolvedSource || options.metadata?.source,
175
+ eventId: resolvedEventId || options.metadata?.eventId,
176
+ ...replayMeta,
177
+ }),
178
+ });
179
+ },
117
180
  };
118
181
  }
@@ -1,3 +1,3 @@
1
1
  export { createTernControls } from './controls';
2
2
  export { handleQueuedRequest, handleProcess, handleReceive, resolveQueueConfig, } from './queue';
3
- export type { DLQMessage, EventFilter, QueueOption, QueuedMessage, ReplayResult, ResolvedQueueConfig, TernControlsConfig, TernEvent, } from './types';
3
+ export type { ControlAlertOptions, DLQMessage, EventFilter, QueueOption, QueuedMessage, ReplayResult, ResolvedQueueConfig, TernControls, TernControlsConfig, TernEvent, } from './types';
@@ -194,7 +194,11 @@ async function handleReceive(request, platform, secret, queueConfig, toleranceIn
194
194
  }
195
195
  try {
196
196
  await client.publishJSON(publishPayload);
197
- return new Response(JSON.stringify({ queued: true }), {
197
+ return new Response(JSON.stringify({
198
+ queued: true,
199
+ platform,
200
+ eventId: verificationResult.eventId,
201
+ }), {
198
202
  status: 200,
199
203
  headers: { 'Content-Type': 'application/json' },
200
204
  });
@@ -1,4 +1,5 @@
1
1
  import { WebhookPlatform } from '../types';
2
+ import type { SendAlertOptions, SendAlertSummary } from '../notifications/types';
2
3
  export type QueueOption = true | {
3
4
  token: string;
4
5
  signingKey: string;
@@ -13,6 +14,10 @@ export interface ResolvedQueueConfig {
13
14
  }
14
15
  export interface TernControlsConfig {
15
16
  token: string;
17
+ notifications?: {
18
+ slackWebhookUrl?: string;
19
+ discordWebhookUrl?: string;
20
+ };
16
21
  }
17
22
  export interface DLQMessage {
18
23
  id: string;
@@ -45,3 +50,16 @@ export interface QueuedMessage {
45
50
  payload: unknown;
46
51
  metadata: Record<string, unknown>;
47
52
  }
53
+ export type ControlAlertOptions = (SendAlertOptions & {
54
+ dlq: true;
55
+ dlqId: string;
56
+ }) | (SendAlertOptions & {
57
+ dlq?: false;
58
+ dlqId?: never;
59
+ });
60
+ export interface TernControls {
61
+ dlq: () => Promise<DLQMessage[]>;
62
+ replay: (dlqId: string) => Promise<ReplayResult>;
63
+ events: (filter?: EventFilter) => Promise<TernEvent[]>;
64
+ alert: (options?: ControlAlertOptions) => Promise<SendAlertSummary>;
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hookflo/tern",
3
- "version": "4.1.1",
3
+ "version": "4.2.0",
4
4
  "description": "A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",