@gravito/echo 3.0.0 → 3.1.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.
- package/README.md +211 -0
- package/dist/atlas/src/DB.d.ts +301 -0
- package/dist/atlas/src/OrbitAtlas.d.ts +9 -0
- package/dist/atlas/src/config/defineConfig.d.ts +14 -0
- package/dist/atlas/src/config/index.d.ts +7 -0
- package/dist/atlas/src/config/loadConfig.d.ts +48 -0
- package/dist/atlas/src/connection/Connection.d.ts +108 -0
- package/dist/atlas/src/connection/ConnectionManager.d.ts +111 -0
- package/dist/atlas/src/drivers/BunSQLDriver.d.ts +32 -0
- package/dist/atlas/src/drivers/BunSQLPreparedStatement.d.ts +118 -0
- package/dist/atlas/src/drivers/MongoDBDriver.d.ts +36 -0
- package/dist/atlas/src/drivers/MySQLDriver.d.ts +66 -0
- package/dist/atlas/src/drivers/PostgresDriver.d.ts +83 -0
- package/dist/atlas/src/drivers/RedisDriver.d.ts +43 -0
- package/dist/atlas/src/drivers/SQLiteDriver.d.ts +45 -0
- package/dist/atlas/src/drivers/types.d.ts +260 -0
- package/dist/atlas/src/errors/index.d.ts +45 -0
- package/dist/atlas/src/grammar/Grammar.d.ts +342 -0
- package/dist/atlas/src/grammar/MongoGrammar.d.ts +47 -0
- package/dist/atlas/src/grammar/MySQLGrammar.d.ts +54 -0
- package/dist/atlas/src/grammar/NullGrammar.d.ts +35 -0
- package/dist/atlas/src/grammar/PostgresGrammar.d.ts +62 -0
- package/dist/atlas/src/grammar/SQLiteGrammar.d.ts +32 -0
- package/dist/atlas/src/index.d.ts +67 -0
- package/dist/atlas/src/migration/Migration.d.ts +64 -0
- package/dist/atlas/src/migration/MigrationRepository.d.ts +65 -0
- package/dist/atlas/src/migration/Migrator.d.ts +110 -0
- package/dist/atlas/src/migration/index.d.ts +6 -0
- package/dist/atlas/src/observability/AtlasMetrics.d.ts +11 -0
- package/dist/atlas/src/observability/AtlasObservability.d.ts +15 -0
- package/dist/atlas/src/observability/AtlasTracer.d.ts +12 -0
- package/dist/atlas/src/observability/index.d.ts +9 -0
- package/dist/atlas/src/orm/index.d.ts +5 -0
- package/dist/atlas/src/orm/model/DirtyTracker.d.ts +121 -0
- package/dist/atlas/src/orm/model/Model.d.ts +449 -0
- package/dist/atlas/src/orm/model/ModelRegistry.d.ts +20 -0
- package/dist/atlas/src/orm/model/concerns/HasAttributes.d.ts +136 -0
- package/dist/atlas/src/orm/model/concerns/HasEvents.d.ts +36 -0
- package/dist/atlas/src/orm/model/concerns/HasPersistence.d.ts +87 -0
- package/dist/atlas/src/orm/model/concerns/HasRelationships.d.ts +117 -0
- package/dist/atlas/src/orm/model/concerns/HasSerialization.d.ts +64 -0
- package/dist/atlas/src/orm/model/concerns/applyMixins.d.ts +15 -0
- package/dist/atlas/src/orm/model/concerns/index.d.ts +12 -0
- package/dist/atlas/src/orm/model/decorators.d.ts +109 -0
- package/dist/atlas/src/orm/model/errors.d.ts +52 -0
- package/dist/atlas/src/orm/model/index.d.ts +10 -0
- package/dist/atlas/src/orm/model/relationships.d.ts +207 -0
- package/dist/atlas/src/orm/model/types.d.ts +12 -0
- package/dist/atlas/src/orm/schema/SchemaRegistry.d.ts +123 -0
- package/dist/atlas/src/orm/schema/SchemaSniffer.d.ts +54 -0
- package/dist/atlas/src/orm/schema/index.d.ts +6 -0
- package/dist/atlas/src/orm/schema/types.d.ts +85 -0
- package/dist/atlas/src/query/Expression.d.ts +60 -0
- package/dist/atlas/src/query/NPlusOneDetector.d.ts +10 -0
- package/dist/atlas/src/query/QueryBuilder.d.ts +573 -0
- package/dist/atlas/src/query/clauses/GroupByClause.d.ts +51 -0
- package/dist/atlas/src/query/clauses/HavingClause.d.ts +70 -0
- package/dist/atlas/src/query/clauses/JoinClause.d.ts +87 -0
- package/dist/atlas/src/query/clauses/LimitClause.d.ts +82 -0
- package/dist/atlas/src/query/clauses/OrderByClause.d.ts +69 -0
- package/dist/atlas/src/query/clauses/SelectClause.d.ts +71 -0
- package/dist/atlas/src/query/clauses/WhereClause.d.ts +167 -0
- package/dist/atlas/src/query/clauses/index.d.ts +11 -0
- package/dist/atlas/src/schema/Blueprint.d.ts +276 -0
- package/dist/atlas/src/schema/ColumnDefinition.d.ts +154 -0
- package/dist/atlas/src/schema/ForeignKeyDefinition.d.ts +37 -0
- package/dist/atlas/src/schema/Schema.d.ts +131 -0
- package/dist/atlas/src/schema/grammars/MySQLSchemaGrammar.d.ts +23 -0
- package/dist/atlas/src/schema/grammars/PostgresSchemaGrammar.d.ts +26 -0
- package/dist/atlas/src/schema/grammars/SQLiteSchemaGrammar.d.ts +28 -0
- package/dist/atlas/src/schema/grammars/SchemaGrammar.d.ts +97 -0
- package/dist/atlas/src/schema/grammars/index.d.ts +7 -0
- package/dist/atlas/src/schema/index.d.ts +8 -0
- package/dist/atlas/src/seed/Factory.d.ts +90 -0
- package/dist/atlas/src/seed/Seeder.d.ts +28 -0
- package/dist/atlas/src/seed/SeederRunner.d.ts +74 -0
- package/dist/atlas/src/seed/index.d.ts +6 -0
- package/dist/atlas/src/types/index.d.ts +1100 -0
- package/dist/atlas/src/utils/levenshtein.d.ts +9 -0
- package/dist/core/src/Application.d.ts +215 -0
- package/dist/core/src/CommandKernel.d.ts +33 -0
- package/dist/core/src/ConfigManager.d.ts +26 -0
- package/dist/core/src/Container.d.ts +108 -0
- package/dist/core/src/ErrorHandler.d.ts +63 -0
- package/dist/core/src/Event.d.ts +5 -0
- package/dist/core/src/EventManager.d.ts +123 -0
- package/dist/core/src/GlobalErrorHandlers.d.ts +47 -0
- package/dist/core/src/GravitoServer.d.ts +28 -0
- package/dist/core/src/HookManager.d.ts +496 -0
- package/dist/core/src/Listener.d.ts +4 -0
- package/dist/core/src/Logger.d.ts +20 -0
- package/dist/core/src/PlanetCore.d.ts +289 -0
- package/dist/core/src/Route.d.ts +36 -0
- package/dist/core/src/Router.d.ts +284 -0
- package/dist/core/src/ServiceProvider.d.ts +156 -0
- package/dist/core/src/adapters/GravitoEngineAdapter.d.ts +27 -0
- package/dist/core/src/adapters/PhotonAdapter.d.ts +171 -0
- package/dist/core/src/adapters/bun/BunContext.d.ts +45 -0
- package/dist/core/src/adapters/bun/BunNativeAdapter.d.ts +31 -0
- package/dist/core/src/adapters/bun/BunRequest.d.ts +31 -0
- package/dist/core/src/adapters/bun/RadixNode.d.ts +19 -0
- package/dist/core/src/adapters/bun/RadixRouter.d.ts +31 -0
- package/dist/core/src/adapters/bun/types.d.ts +20 -0
- package/dist/core/src/adapters/photon-types.d.ts +73 -0
- package/dist/core/src/adapters/types.d.ts +235 -0
- package/dist/core/src/engine/AOTRouter.d.ts +124 -0
- package/dist/core/src/engine/FastContext.d.ts +100 -0
- package/dist/core/src/engine/Gravito.d.ts +137 -0
- package/dist/core/src/engine/MinimalContext.d.ts +79 -0
- package/dist/core/src/engine/analyzer.d.ts +27 -0
- package/dist/core/src/engine/constants.d.ts +23 -0
- package/dist/core/src/engine/index.d.ts +26 -0
- package/dist/core/src/engine/path.d.ts +26 -0
- package/dist/core/src/engine/pool.d.ts +83 -0
- package/dist/core/src/engine/types.d.ts +143 -0
- package/dist/core/src/events/CircuitBreaker.d.ts +229 -0
- package/dist/core/src/events/DeadLetterQueue.d.ts +145 -0
- package/dist/core/src/events/EventBackend.d.ts +11 -0
- package/dist/core/src/events/EventOptions.d.ts +109 -0
- package/dist/core/src/events/EventPriorityQueue.d.ts +202 -0
- package/dist/core/src/events/IdempotencyCache.d.ts +60 -0
- package/dist/core/src/events/index.d.ts +14 -0
- package/dist/core/src/events/observability/EventMetrics.d.ts +132 -0
- package/dist/core/src/events/observability/EventTracer.d.ts +68 -0
- package/dist/core/src/events/observability/EventTracing.d.ts +161 -0
- package/dist/core/src/events/observability/OTelEventMetrics.d.ts +240 -0
- package/dist/core/src/events/observability/ObservableHookManager.d.ts +108 -0
- package/dist/core/src/events/observability/index.d.ts +20 -0
- package/dist/core/src/events/observability/metrics-types.d.ts +16 -0
- package/dist/core/src/events/types.d.ts +75 -0
- package/dist/core/src/exceptions/AuthenticationException.d.ts +8 -0
- package/dist/core/src/exceptions/AuthorizationException.d.ts +8 -0
- package/dist/core/src/exceptions/CircularDependencyException.d.ts +9 -0
- package/dist/core/src/exceptions/GravitoException.d.ts +23 -0
- package/dist/core/src/exceptions/HttpException.d.ts +9 -0
- package/dist/core/src/exceptions/ModelNotFoundException.d.ts +10 -0
- package/dist/core/src/exceptions/ValidationException.d.ts +22 -0
- package/dist/core/src/exceptions/index.d.ts +7 -0
- package/dist/core/src/helpers/Arr.d.ts +19 -0
- package/dist/core/src/helpers/Str.d.ts +23 -0
- package/dist/core/src/helpers/data.d.ts +25 -0
- package/dist/core/src/helpers/errors.d.ts +34 -0
- package/dist/core/src/helpers/response.d.ts +41 -0
- package/dist/core/src/helpers.d.ts +338 -0
- package/dist/core/src/http/CookieJar.d.ts +51 -0
- package/dist/core/src/http/cookie.d.ts +29 -0
- package/dist/core/src/http/middleware/BodySizeLimit.d.ts +16 -0
- package/dist/core/src/http/middleware/Cors.d.ts +24 -0
- package/dist/core/src/http/middleware/Csrf.d.ts +23 -0
- package/dist/core/src/http/middleware/HeaderTokenGate.d.ts +28 -0
- package/dist/core/src/http/middleware/SecurityHeaders.d.ts +29 -0
- package/dist/core/src/http/middleware/ThrottleRequests.d.ts +18 -0
- package/dist/core/src/http/types.d.ts +355 -0
- package/dist/core/src/index.d.ts +76 -0
- package/dist/core/src/instrumentation/index.d.ts +35 -0
- package/dist/core/src/instrumentation/opentelemetry.d.ts +178 -0
- package/dist/core/src/instrumentation/types.d.ts +182 -0
- package/dist/core/src/reliability/DeadLetterQueueManager.d.ts +316 -0
- package/dist/core/src/reliability/RetryPolicy.d.ts +217 -0
- package/dist/core/src/reliability/index.d.ts +6 -0
- package/dist/core/src/router/ControllerDispatcher.d.ts +12 -0
- package/dist/core/src/router/RequestValidator.d.ts +20 -0
- package/dist/core/src/runtime.d.ts +119 -0
- package/dist/core/src/security/Encrypter.d.ts +33 -0
- package/dist/core/src/security/Hasher.d.ts +29 -0
- package/dist/core/src/testing/HttpTester.d.ts +39 -0
- package/dist/core/src/testing/TestResponse.d.ts +78 -0
- package/dist/core/src/testing/index.d.ts +2 -0
- package/dist/core/src/types/events.d.ts +94 -0
- package/dist/echo/src/OrbitEcho.d.ts +115 -0
- package/dist/echo/src/dlq/DeadLetterQueue.d.ts +94 -0
- package/dist/echo/src/dlq/MemoryDeadLetterQueue.d.ts +36 -0
- package/dist/echo/src/dlq/index.d.ts +2 -0
- package/dist/echo/src/index.d.ts +64 -0
- package/dist/echo/src/middleware/RequestBufferMiddleware.d.ts +62 -0
- package/dist/echo/src/middleware/index.d.ts +8 -0
- package/dist/echo/src/observability/index.d.ts +3 -0
- package/dist/echo/src/observability/logging/ConsoleEchoLogger.d.ts +37 -0
- package/dist/echo/src/observability/logging/EchoLogger.d.ts +38 -0
- package/dist/echo/src/observability/logging/index.d.ts +2 -0
- package/dist/echo/src/observability/metrics/MetricsProvider.d.ts +69 -0
- package/dist/echo/src/observability/metrics/NoopMetricsProvider.d.ts +17 -0
- package/dist/echo/src/observability/metrics/PrometheusMetricsProvider.d.ts +39 -0
- package/dist/echo/src/observability/metrics/index.d.ts +3 -0
- package/dist/echo/src/observability/tracing/NoopTracer.d.ts +33 -0
- package/dist/echo/src/observability/tracing/Tracer.d.ts +75 -0
- package/dist/echo/src/observability/tracing/index.d.ts +2 -0
- package/dist/echo/src/providers/GenericProvider.d.ts +53 -0
- package/dist/echo/src/providers/GitHubProvider.d.ts +35 -0
- package/dist/echo/src/providers/LinearProvider.d.ts +27 -0
- package/dist/echo/src/providers/PaddleProvider.d.ts +31 -0
- package/dist/echo/src/providers/ShopifyProvider.d.ts +27 -0
- package/dist/echo/src/providers/SlackProvider.d.ts +27 -0
- package/dist/echo/src/providers/StripeProvider.d.ts +38 -0
- package/dist/echo/src/providers/TwilioProvider.d.ts +31 -0
- package/dist/echo/src/providers/base/BaseProvider.d.ts +87 -0
- package/dist/echo/src/providers/base/HeaderUtils.d.ts +34 -0
- package/dist/echo/src/providers/index.d.ts +14 -0
- package/dist/echo/src/receive/SignatureValidator.d.ts +67 -0
- package/dist/echo/src/receive/WebhookReceiver.d.ts +185 -0
- package/dist/echo/src/receive/index.d.ts +2 -0
- package/dist/echo/src/replay/WebhookReplayService.d.ts +35 -0
- package/dist/echo/src/replay/index.d.ts +1 -0
- package/dist/echo/src/resilience/CircuitBreaker.d.ts +117 -0
- package/dist/echo/src/resilience/index.d.ts +10 -0
- package/dist/echo/src/rotation/KeyRotationManager.d.ts +127 -0
- package/dist/echo/src/rotation/index.d.ts +10 -0
- package/dist/echo/src/send/WebhookDispatcher.d.ts +198 -0
- package/dist/echo/src/send/index.d.ts +1 -0
- package/dist/echo/src/storage/MemoryWebhookStore.d.ts +14 -0
- package/dist/echo/src/storage/WebhookStore.d.ts +236 -0
- package/dist/echo/src/storage/index.d.ts +2 -0
- package/dist/echo/src/types.d.ts +756 -0
- package/dist/index.js +1332 -190
- package/dist/index.js.map +28 -10
- package/dist/photon/src/index.d.ts +84 -0
- package/dist/photon/src/middleware/binary.d.ts +31 -0
- package/dist/photon/src/middleware/htmx.d.ts +39 -0
- package/dist/photon/src/middleware/ratelimit.d.ts +157 -0
- package/dist/photon/src/openapi.d.ts +19 -0
- package/package.json +7 -5
package/dist/index.js
CHANGED
|
@@ -1,4 +1,205 @@
|
|
|
1
1
|
// @bun
|
|
2
|
+
// src/dlq/MemoryDeadLetterQueue.ts
|
|
3
|
+
class MemoryDeadLetterQueue {
|
|
4
|
+
queue = new Map;
|
|
5
|
+
async enqueue(event) {
|
|
6
|
+
const id = event.id ?? crypto.randomUUID();
|
|
7
|
+
this.queue.set(id, { ...event, id });
|
|
8
|
+
return id;
|
|
9
|
+
}
|
|
10
|
+
async peek(limit = 10) {
|
|
11
|
+
return Array.from(this.queue.values()).sort((a, b) => a.failedAt.getTime() - b.failedAt.getTime()).slice(0, limit);
|
|
12
|
+
}
|
|
13
|
+
async dequeue(id) {
|
|
14
|
+
this.queue.delete(id);
|
|
15
|
+
}
|
|
16
|
+
async size() {
|
|
17
|
+
return this.queue.size;
|
|
18
|
+
}
|
|
19
|
+
async clear() {
|
|
20
|
+
this.queue.clear();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// src/middleware/RequestBufferMiddleware.ts
|
|
24
|
+
var DEFAULT_CONFIG = {
|
|
25
|
+
enabled: true,
|
|
26
|
+
maxBodySize: 10 * 1024 * 1024,
|
|
27
|
+
skipContentTypes: ["multipart/form-data", "application/octet-stream"]
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
class RequestBufferMiddleware {
|
|
31
|
+
config;
|
|
32
|
+
constructor(config = {}) {
|
|
33
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
34
|
+
}
|
|
35
|
+
handler() {
|
|
36
|
+
return async (c, next) => {
|
|
37
|
+
if (!this.config.enabled) {
|
|
38
|
+
return await next();
|
|
39
|
+
}
|
|
40
|
+
const contentType = c.req.header("content-type") ?? "";
|
|
41
|
+
if (this.config.skipContentTypes.some((type) => contentType.includes(type))) {
|
|
42
|
+
return await next();
|
|
43
|
+
}
|
|
44
|
+
const rawBody = await this.readRawBody(c);
|
|
45
|
+
const bodySize = typeof rawBody === "string" ? Buffer.byteLength(rawBody, "utf-8") : rawBody.length;
|
|
46
|
+
if (bodySize > this.config.maxBodySize) {
|
|
47
|
+
return c.json({ error: "Request body too large" }, 413);
|
|
48
|
+
}
|
|
49
|
+
const buffered = {
|
|
50
|
+
rawBody,
|
|
51
|
+
headers: c.req.header(),
|
|
52
|
+
bufferedAt: new Date
|
|
53
|
+
};
|
|
54
|
+
c.set("bufferedRequest", buffered);
|
|
55
|
+
return await next();
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
async readRawBody(c) {
|
|
59
|
+
const existing = c.get("bufferedRequest");
|
|
60
|
+
if (existing) {
|
|
61
|
+
return existing.rawBody;
|
|
62
|
+
}
|
|
63
|
+
const body = await c.req.text();
|
|
64
|
+
return body;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function createRequestBufferMiddleware(config) {
|
|
68
|
+
return new RequestBufferMiddleware(config).handler();
|
|
69
|
+
}
|
|
70
|
+
// src/observability/logging/ConsoleEchoLogger.ts
|
|
71
|
+
class ConsoleEchoLogger {
|
|
72
|
+
formatContext(base, extra) {
|
|
73
|
+
return {
|
|
74
|
+
module: "echo",
|
|
75
|
+
timestamp: new Date().toISOString(),
|
|
76
|
+
...base,
|
|
77
|
+
...extra
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
debug(message, context) {
|
|
81
|
+
console.debug(JSON.stringify({
|
|
82
|
+
level: "debug",
|
|
83
|
+
message,
|
|
84
|
+
...this.formatContext({}, context)
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
info(message, context) {
|
|
88
|
+
console.info(JSON.stringify({
|
|
89
|
+
level: "info",
|
|
90
|
+
message,
|
|
91
|
+
...this.formatContext({}, context)
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
warn(message, context) {
|
|
95
|
+
console.warn(JSON.stringify({
|
|
96
|
+
level: "warn",
|
|
97
|
+
message,
|
|
98
|
+
...this.formatContext({}, context)
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
error(message, context) {
|
|
102
|
+
console.error(JSON.stringify({
|
|
103
|
+
level: "error",
|
|
104
|
+
message,
|
|
105
|
+
...this.formatContext({}, context)
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// src/observability/metrics/MetricsProvider.ts
|
|
110
|
+
var EchoMetrics = {
|
|
111
|
+
INCOMING_TOTAL: "echo_incoming_webhooks_total",
|
|
112
|
+
INCOMING_DURATION: "echo_incoming_duration_seconds",
|
|
113
|
+
INCOMING_VERIFICATION_FAILURES: "echo_incoming_verification_failures_total",
|
|
114
|
+
OUTGOING_TOTAL: "echo_outgoing_webhooks_total",
|
|
115
|
+
OUTGOING_DURATION: "echo_outgoing_duration_seconds",
|
|
116
|
+
OUTGOING_RETRIES: "echo_outgoing_retries_total",
|
|
117
|
+
OUTGOING_FAILURES: "echo_outgoing_failures_total",
|
|
118
|
+
DLQ_SIZE: "echo_dlq_size",
|
|
119
|
+
DLQ_ENQUEUED: "echo_dlq_enqueued_total",
|
|
120
|
+
DLQ_PROCESSED: "echo_dlq_processed_total"
|
|
121
|
+
};
|
|
122
|
+
// src/observability/metrics/NoopMetricsProvider.ts
|
|
123
|
+
class NoopMetricsProvider {
|
|
124
|
+
increment() {}
|
|
125
|
+
histogram() {}
|
|
126
|
+
gauge() {}
|
|
127
|
+
}
|
|
128
|
+
// src/observability/metrics/PrometheusMetricsProvider.ts
|
|
129
|
+
class PrometheusMetricsProvider {
|
|
130
|
+
counters = new Map;
|
|
131
|
+
histograms = new Map;
|
|
132
|
+
gauges = new Map;
|
|
133
|
+
increment(name, labels = {}) {
|
|
134
|
+
const key = this.buildKey(name, labels);
|
|
135
|
+
const counterMap = this.counters.get(name) ?? new Map;
|
|
136
|
+
counterMap.set(key, (counterMap.get(key) ?? 0) + 1);
|
|
137
|
+
this.counters.set(name, counterMap);
|
|
138
|
+
}
|
|
139
|
+
histogram(name, value, labels = {}) {
|
|
140
|
+
const key = this.buildKey(name, labels);
|
|
141
|
+
const values = this.histograms.get(key) ?? [];
|
|
142
|
+
values.push(value);
|
|
143
|
+
this.histograms.set(key, values);
|
|
144
|
+
}
|
|
145
|
+
gauge(name, value, labels = {}) {
|
|
146
|
+
const key = this.buildKey(name, labels);
|
|
147
|
+
this.gauges.set(key, value);
|
|
148
|
+
}
|
|
149
|
+
export() {
|
|
150
|
+
const lines = [];
|
|
151
|
+
for (const [name, counterMap] of this.counters) {
|
|
152
|
+
lines.push(`# TYPE ${name} counter`);
|
|
153
|
+
for (const [key, value] of counterMap) {
|
|
154
|
+
lines.push(`${key} ${value}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const [key, value] of this.gauges) {
|
|
158
|
+
lines.push(`${key} ${value}`);
|
|
159
|
+
}
|
|
160
|
+
return lines.join(`
|
|
161
|
+
`);
|
|
162
|
+
}
|
|
163
|
+
buildKey(name, labels) {
|
|
164
|
+
if (Object.keys(labels).length === 0) {
|
|
165
|
+
return name;
|
|
166
|
+
}
|
|
167
|
+
const labelStr = Object.entries(labels).map(([k, v]) => `${k}="${v}"`).join(",");
|
|
168
|
+
return `${name}{${labelStr}}`;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// src/observability/tracing/NoopTracer.ts
|
|
172
|
+
class NoopSpan {
|
|
173
|
+
setAttribute() {
|
|
174
|
+
return this;
|
|
175
|
+
}
|
|
176
|
+
setAttributes() {
|
|
177
|
+
return this;
|
|
178
|
+
}
|
|
179
|
+
addEvent() {
|
|
180
|
+
return this;
|
|
181
|
+
}
|
|
182
|
+
setStatus() {
|
|
183
|
+
return this;
|
|
184
|
+
}
|
|
185
|
+
end() {}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
class NoopTracer {
|
|
189
|
+
startSpan() {
|
|
190
|
+
return new NoopSpan;
|
|
191
|
+
}
|
|
192
|
+
async withSpan(_name, fn) {
|
|
193
|
+
return fn(new NoopSpan);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// src/observability/tracing/Tracer.ts
|
|
197
|
+
var SpanStatusCode;
|
|
198
|
+
((SpanStatusCode2) => {
|
|
199
|
+
SpanStatusCode2[SpanStatusCode2["UNSET"] = 0] = "UNSET";
|
|
200
|
+
SpanStatusCode2[SpanStatusCode2["OK"] = 1] = "OK";
|
|
201
|
+
SpanStatusCode2[SpanStatusCode2["ERROR"] = 2] = "ERROR";
|
|
202
|
+
})(SpanStatusCode ||= {});
|
|
2
203
|
// src/receive/SignatureValidator.ts
|
|
3
204
|
async function computeHmacSha256(payload, secret) {
|
|
4
205
|
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
@@ -6,12 +207,24 @@ async function computeHmacSha256(payload, secret) {
|
|
|
6
207
|
const signature = await crypto.subtle.sign("HMAC", key, payloadBuffer);
|
|
7
208
|
return Buffer.from(signature).toString("hex");
|
|
8
209
|
}
|
|
210
|
+
async function computeHmacSha256Base64(payload, secret) {
|
|
211
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
212
|
+
const payloadBuffer = typeof payload === "string" ? new TextEncoder().encode(payload) : new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength);
|
|
213
|
+
const signature = await crypto.subtle.sign("HMAC", key, payloadBuffer);
|
|
214
|
+
return Buffer.from(signature).toString("base64");
|
|
215
|
+
}
|
|
9
216
|
async function computeHmacSha1(payload, secret) {
|
|
10
217
|
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
|
|
11
218
|
const payloadBuffer = typeof payload === "string" ? new TextEncoder().encode(payload) : new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength);
|
|
12
219
|
const signature = await crypto.subtle.sign("HMAC", key, payloadBuffer);
|
|
13
220
|
return Buffer.from(signature).toString("hex");
|
|
14
221
|
}
|
|
222
|
+
async function computeHmacSha1Base64(payload, secret) {
|
|
223
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
|
|
224
|
+
const payloadBuffer = typeof payload === "string" ? new TextEncoder().encode(payload) : new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength);
|
|
225
|
+
const signature = await crypto.subtle.sign("HMAC", key, payloadBuffer);
|
|
226
|
+
return Buffer.from(signature).toString("base64");
|
|
227
|
+
}
|
|
15
228
|
function timingSafeEqual(a, b) {
|
|
16
229
|
if (a.length !== b.length) {
|
|
17
230
|
return false;
|
|
@@ -46,106 +259,117 @@ function parseStripeSignature(header) {
|
|
|
46
259
|
return { timestamp, signatures };
|
|
47
260
|
}
|
|
48
261
|
|
|
262
|
+
// src/providers/base/HeaderUtils.ts
|
|
263
|
+
function getHeader(headers, name) {
|
|
264
|
+
const value = headers[name] ?? headers[name.toLowerCase()];
|
|
265
|
+
return Array.isArray(value) ? value[0] : value;
|
|
266
|
+
}
|
|
267
|
+
function hasHeader(headers, name) {
|
|
268
|
+
return getHeader(headers, name) !== undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// src/providers/base/BaseProvider.ts
|
|
272
|
+
class BaseProvider {
|
|
273
|
+
tolerance;
|
|
274
|
+
constructor(options = {}) {
|
|
275
|
+
this.tolerance = options.tolerance ?? 300;
|
|
276
|
+
}
|
|
277
|
+
getHeader(headers, name) {
|
|
278
|
+
return getHeader(headers, name);
|
|
279
|
+
}
|
|
280
|
+
hasHeader(headers, name) {
|
|
281
|
+
return hasHeader(headers, name);
|
|
282
|
+
}
|
|
283
|
+
createFailure(error) {
|
|
284
|
+
return { valid: false, error };
|
|
285
|
+
}
|
|
286
|
+
createSuccess(payload, options = {}) {
|
|
287
|
+
return {
|
|
288
|
+
valid: true,
|
|
289
|
+
payload,
|
|
290
|
+
eventType: options.eventType,
|
|
291
|
+
webhookId: options.webhookId
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
payloadToString(payload) {
|
|
295
|
+
return typeof payload === "string" ? payload : payload.toString("utf-8");
|
|
296
|
+
}
|
|
297
|
+
safeParseJson(str) {
|
|
298
|
+
try {
|
|
299
|
+
return { success: true, data: JSON.parse(str) };
|
|
300
|
+
} catch {
|
|
301
|
+
return { success: false, error: "Failed to parse webhook payload" };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
49
306
|
// src/providers/GenericProvider.ts
|
|
50
|
-
class GenericProvider {
|
|
307
|
+
class GenericProvider extends BaseProvider {
|
|
51
308
|
name = "generic";
|
|
52
309
|
signatureHeader;
|
|
53
310
|
timestampHeader;
|
|
54
|
-
tolerance;
|
|
55
311
|
constructor(options = {}) {
|
|
312
|
+
super(options);
|
|
56
313
|
this.signatureHeader = options.signatureHeader ?? "x-webhook-signature";
|
|
57
314
|
this.timestampHeader = options.timestampHeader ?? "x-webhook-timestamp";
|
|
58
|
-
this.tolerance = options.tolerance ?? 300;
|
|
59
315
|
}
|
|
60
316
|
async verify(payload, headers, secret) {
|
|
61
317
|
const signature = this.getHeader(headers, this.signatureHeader);
|
|
62
318
|
if (!signature) {
|
|
63
|
-
return {
|
|
64
|
-
valid: false,
|
|
65
|
-
error: `Missing signature header: ${this.signatureHeader}`
|
|
66
|
-
};
|
|
319
|
+
return this.createFailure(`Missing signature header: ${this.signatureHeader}`);
|
|
67
320
|
}
|
|
68
321
|
const timestampStr = this.getHeader(headers, this.timestampHeader);
|
|
69
322
|
if (timestampStr) {
|
|
70
323
|
const timestamp = parseInt(timestampStr, 10);
|
|
71
324
|
if (Number.isNaN(timestamp) || !validateTimestamp(timestamp, this.tolerance)) {
|
|
72
|
-
return
|
|
73
|
-
valid: false,
|
|
74
|
-
error: "Timestamp validation failed"
|
|
75
|
-
};
|
|
325
|
+
return this.createFailure("Timestamp validation failed");
|
|
76
326
|
}
|
|
77
327
|
}
|
|
78
|
-
const payloadStr =
|
|
328
|
+
const payloadStr = this.payloadToString(payload);
|
|
79
329
|
const expectedSignature = await computeHmacSha256(payloadStr, secret);
|
|
80
330
|
if (!timingSafeEqual(signature.toLowerCase(), expectedSignature.toLowerCase())) {
|
|
81
|
-
return
|
|
82
|
-
valid: false,
|
|
83
|
-
error: "Signature verification failed"
|
|
84
|
-
};
|
|
331
|
+
return this.createFailure("Signature verification failed");
|
|
85
332
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return
|
|
89
|
-
valid: true,
|
|
90
|
-
payload: parsed,
|
|
91
|
-
eventType: parsed.type ?? parsed.event ?? parsed.eventType,
|
|
92
|
-
webhookId: parsed.id ?? parsed.webhookId
|
|
93
|
-
};
|
|
94
|
-
} catch {
|
|
95
|
-
return {
|
|
96
|
-
valid: true,
|
|
97
|
-
payload: payloadStr
|
|
98
|
-
};
|
|
333
|
+
const parseResult = this.safeParseJson(payloadStr);
|
|
334
|
+
if (!parseResult.success) {
|
|
335
|
+
return this.createSuccess(payloadStr);
|
|
99
336
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
337
|
+
const parsed = parseResult.data;
|
|
338
|
+
return this.createSuccess(parsed, {
|
|
339
|
+
eventType: parsed.type ?? parsed.event ?? parsed.eventType,
|
|
340
|
+
webhookId: parsed.id ?? parsed.webhookId
|
|
341
|
+
});
|
|
104
342
|
}
|
|
105
343
|
}
|
|
106
344
|
|
|
107
345
|
// src/providers/GitHubProvider.ts
|
|
108
|
-
class GitHubProvider {
|
|
346
|
+
class GitHubProvider extends BaseProvider {
|
|
109
347
|
name = "github";
|
|
110
348
|
async verify(payload, headers, secret) {
|
|
111
349
|
const signature = this.getHeader(headers, "x-hub-signature-256");
|
|
112
350
|
if (!signature) {
|
|
113
|
-
return
|
|
114
|
-
valid: false,
|
|
115
|
-
error: "Missing X-Hub-Signature-256 header"
|
|
116
|
-
};
|
|
351
|
+
return this.createFailure("Missing X-Hub-Signature-256 header");
|
|
117
352
|
}
|
|
118
353
|
if (!signature.startsWith("sha256=")) {
|
|
119
|
-
return
|
|
120
|
-
valid: false,
|
|
121
|
-
error: "Invalid signature format (expected sha256=...)"
|
|
122
|
-
};
|
|
354
|
+
return this.createFailure("Invalid signature format (expected sha256=...)");
|
|
123
355
|
}
|
|
124
356
|
const signatureValue = signature.slice(7);
|
|
125
|
-
const payloadStr =
|
|
357
|
+
const payloadStr = this.payloadToString(payload);
|
|
126
358
|
const expectedSignature = await computeHmacSha256(payloadStr, secret);
|
|
127
359
|
if (!timingSafeEqual(signatureValue.toLowerCase(), expectedSignature.toLowerCase())) {
|
|
128
|
-
return
|
|
129
|
-
valid: false,
|
|
130
|
-
error: "Signature verification failed"
|
|
131
|
-
};
|
|
360
|
+
return this.createFailure("Signature verification failed");
|
|
132
361
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const deliveryId = this.getHeader(headers, "x-github-delivery");
|
|
137
|
-
return {
|
|
138
|
-
valid: true,
|
|
139
|
-
payload: event,
|
|
140
|
-
eventType: eventType ?? undefined,
|
|
141
|
-
webhookId: deliveryId ?? undefined
|
|
142
|
-
};
|
|
143
|
-
} catch {
|
|
144
|
-
return {
|
|
145
|
-
valid: false,
|
|
146
|
-
error: "Failed to parse webhook payload"
|
|
147
|
-
};
|
|
362
|
+
const parseResult = this.safeParseJson(payloadStr);
|
|
363
|
+
if (!parseResult.success) {
|
|
364
|
+
return this.createFailure("Failed to parse webhook payload");
|
|
148
365
|
}
|
|
366
|
+
const event = parseResult.data;
|
|
367
|
+
const eventType = this.getHeader(headers, "x-github-event");
|
|
368
|
+
const deliveryId = this.getHeader(headers, "x-github-delivery");
|
|
369
|
+
return this.createSuccess(event, {
|
|
370
|
+
eventType: eventType ?? undefined,
|
|
371
|
+
webhookId: deliveryId ?? undefined
|
|
372
|
+
});
|
|
149
373
|
}
|
|
150
374
|
parseEventType(payload) {
|
|
151
375
|
if (typeof payload === "object" && payload !== null && "action" in payload) {
|
|
@@ -153,65 +377,180 @@ class GitHubProvider {
|
|
|
153
377
|
}
|
|
154
378
|
return;
|
|
155
379
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// src/providers/LinearProvider.ts
|
|
383
|
+
class LinearProvider extends BaseProvider {
|
|
384
|
+
name = "linear";
|
|
385
|
+
async verify(payload, headers, secret) {
|
|
386
|
+
const signature = this.getHeader(headers, "linear-signature");
|
|
387
|
+
if (!signature) {
|
|
388
|
+
return this.createFailure("Missing Linear-Signature header");
|
|
389
|
+
}
|
|
390
|
+
const payloadStr = this.payloadToString(payload);
|
|
391
|
+
const expectedSignature = await computeHmacSha256(payloadStr, secret);
|
|
392
|
+
if (!timingSafeEqual(signature, expectedSignature)) {
|
|
393
|
+
return this.createFailure("Signature verification failed");
|
|
394
|
+
}
|
|
395
|
+
const parseResult = this.safeParseJson(payloadStr);
|
|
396
|
+
if (!parseResult.success) {
|
|
397
|
+
return this.createFailure(parseResult.error);
|
|
398
|
+
}
|
|
399
|
+
const data = parseResult.data;
|
|
400
|
+
return this.createSuccess(data, {
|
|
401
|
+
eventType: data.type ?? data.action,
|
|
402
|
+
webhookId: this.getHeader(headers, "linear-delivery")
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// src/providers/PaddleProvider.ts
|
|
408
|
+
class PaddleProvider extends BaseProvider {
|
|
409
|
+
name = "paddle";
|
|
410
|
+
async verify(payload, headers, secret) {
|
|
411
|
+
const signatureHeader = this.getHeader(headers, "paddle-signature");
|
|
412
|
+
if (!signatureHeader) {
|
|
413
|
+
return this.createFailure("Missing Paddle-Signature header");
|
|
414
|
+
}
|
|
415
|
+
const parsed = this.parsePaddleSignature(signatureHeader);
|
|
416
|
+
if (!parsed) {
|
|
417
|
+
return this.createFailure("Invalid Paddle-Signature format");
|
|
418
|
+
}
|
|
419
|
+
const { timestamp, signature } = parsed;
|
|
420
|
+
if (!validateTimestamp(timestamp, this.tolerance)) {
|
|
421
|
+
return this.createFailure(`Timestamp outside tolerance window (${this.tolerance}s)`);
|
|
422
|
+
}
|
|
423
|
+
const payloadStr = this.payloadToString(payload);
|
|
424
|
+
const signedPayload = `${timestamp}:${payloadStr}`;
|
|
425
|
+
const expectedSignature = await computeHmacSha256(signedPayload, secret);
|
|
426
|
+
if (!timingSafeEqual(signature, expectedSignature)) {
|
|
427
|
+
return this.createFailure("Signature verification failed");
|
|
428
|
+
}
|
|
429
|
+
const parseResult = this.safeParseJson(payloadStr);
|
|
430
|
+
if (!parseResult.success) {
|
|
431
|
+
return this.createFailure(parseResult.error);
|
|
432
|
+
}
|
|
433
|
+
const data = parseResult.data;
|
|
434
|
+
return this.createSuccess(data, {
|
|
435
|
+
eventType: data.event_type,
|
|
436
|
+
webhookId: data.event_id
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
parsePaddleSignature(header) {
|
|
440
|
+
const parts = header.split(";");
|
|
441
|
+
let timestamp;
|
|
442
|
+
let signature;
|
|
443
|
+
for (const part of parts) {
|
|
444
|
+
const [key, value] = part.split("=");
|
|
445
|
+
if (key === "ts" && value) {
|
|
446
|
+
timestamp = parseInt(value, 10);
|
|
447
|
+
} else if (key === "h1" && value) {
|
|
448
|
+
signature = value;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (timestamp === undefined || !signature) {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
return { timestamp, signature };
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// src/providers/ShopifyProvider.ts
|
|
459
|
+
class ShopifyProvider extends BaseProvider {
|
|
460
|
+
name = "shopify";
|
|
461
|
+
async verify(payload, headers, secret) {
|
|
462
|
+
const signature = this.getHeader(headers, "x-shopify-hmac-sha256");
|
|
463
|
+
if (!signature) {
|
|
464
|
+
return this.createFailure("Missing X-Shopify-Hmac-Sha256 header");
|
|
465
|
+
}
|
|
466
|
+
const payloadStr = this.payloadToString(payload);
|
|
467
|
+
const expectedSignature = await computeHmacSha256Base64(payloadStr, secret);
|
|
468
|
+
if (!timingSafeEqual(signature, expectedSignature)) {
|
|
469
|
+
return this.createFailure("Signature verification failed");
|
|
470
|
+
}
|
|
471
|
+
const parseResult = this.safeParseJson(payloadStr);
|
|
472
|
+
if (!parseResult.success) {
|
|
473
|
+
return this.createFailure(parseResult.error);
|
|
474
|
+
}
|
|
475
|
+
return this.createSuccess(parseResult.data, {
|
|
476
|
+
eventType: this.getHeader(headers, "x-shopify-topic"),
|
|
477
|
+
webhookId: this.getHeader(headers, "x-shopify-webhook-id")
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// src/providers/SlackProvider.ts
|
|
483
|
+
class SlackProvider extends BaseProvider {
|
|
484
|
+
name = "slack";
|
|
485
|
+
async verify(payload, headers, secret) {
|
|
486
|
+
const signature = this.getHeader(headers, "x-slack-signature");
|
|
487
|
+
const timestampStr = this.getHeader(headers, "x-slack-request-timestamp");
|
|
488
|
+
if (!signature) {
|
|
489
|
+
return this.createFailure("Missing X-Slack-Signature header");
|
|
490
|
+
}
|
|
491
|
+
if (!timestampStr) {
|
|
492
|
+
return this.createFailure("Missing X-Slack-Request-Timestamp header");
|
|
493
|
+
}
|
|
494
|
+
if (!signature.startsWith("v0=")) {
|
|
495
|
+
return this.createFailure("Invalid signature format (expected v0=...)");
|
|
496
|
+
}
|
|
497
|
+
const timestamp = parseInt(timestampStr, 10);
|
|
498
|
+
if (!validateTimestamp(timestamp, this.tolerance)) {
|
|
499
|
+
return this.createFailure(`Timestamp outside tolerance window (${this.tolerance}s)`);
|
|
500
|
+
}
|
|
501
|
+
const payloadStr = this.payloadToString(payload);
|
|
502
|
+
const sigBasestring = `v0:${timestamp}:${payloadStr}`;
|
|
503
|
+
const expectedSignature = await computeHmacSha256(sigBasestring, secret);
|
|
504
|
+
if (!timingSafeEqual(signature.slice(3), expectedSignature)) {
|
|
505
|
+
return this.createFailure("Signature verification failed");
|
|
506
|
+
}
|
|
507
|
+
const parseResult = this.safeParseJson(payloadStr);
|
|
508
|
+
if (!parseResult.success) {
|
|
509
|
+
return this.createFailure(parseResult.error);
|
|
510
|
+
}
|
|
511
|
+
const data = parseResult.data;
|
|
512
|
+
return this.createSuccess(data, {
|
|
513
|
+
eventType: data.type,
|
|
514
|
+
webhookId: data.event_id
|
|
515
|
+
});
|
|
159
516
|
}
|
|
160
517
|
}
|
|
161
518
|
|
|
162
519
|
// src/providers/StripeProvider.ts
|
|
163
|
-
class StripeProvider {
|
|
520
|
+
class StripeProvider extends BaseProvider {
|
|
164
521
|
name = "stripe";
|
|
165
|
-
tolerance;
|
|
166
522
|
constructor(options = {}) {
|
|
167
|
-
|
|
523
|
+
super(options);
|
|
168
524
|
}
|
|
169
525
|
async verify(payload, headers, secret) {
|
|
170
526
|
const signatureHeader = this.getHeader(headers, "stripe-signature");
|
|
171
527
|
if (!signatureHeader) {
|
|
172
|
-
return
|
|
173
|
-
valid: false,
|
|
174
|
-
error: "Missing Stripe-Signature header"
|
|
175
|
-
};
|
|
528
|
+
return this.createFailure("Missing Stripe-Signature header");
|
|
176
529
|
}
|
|
177
530
|
const parsed = parseStripeSignature(signatureHeader);
|
|
178
531
|
if (!parsed) {
|
|
179
|
-
return
|
|
180
|
-
valid: false,
|
|
181
|
-
error: "Invalid Stripe-Signature header format"
|
|
182
|
-
};
|
|
532
|
+
return this.createFailure("Invalid Stripe-Signature header format");
|
|
183
533
|
}
|
|
184
534
|
const { timestamp, signatures } = parsed;
|
|
185
535
|
if (!validateTimestamp(timestamp, this.tolerance)) {
|
|
186
|
-
return {
|
|
187
|
-
valid: false,
|
|
188
|
-
error: `Timestamp outside tolerance window (${this.tolerance}s)`
|
|
189
|
-
};
|
|
536
|
+
return this.createFailure(`Timestamp outside tolerance window (${this.tolerance}s)`);
|
|
190
537
|
}
|
|
191
|
-
const payloadStr =
|
|
538
|
+
const payloadStr = this.payloadToString(payload);
|
|
192
539
|
const signedPayload = `${timestamp}.${payloadStr}`;
|
|
193
540
|
const expectedSignature = await computeHmacSha256(signedPayload, secret);
|
|
194
541
|
const signatureValid = signatures.some((sig) => timingSafeEqual(sig.toLowerCase(), expectedSignature.toLowerCase()));
|
|
195
542
|
if (!signatureValid) {
|
|
196
|
-
return
|
|
197
|
-
valid: false,
|
|
198
|
-
error: "Signature verification failed"
|
|
199
|
-
};
|
|
543
|
+
return this.createFailure("Signature verification failed");
|
|
200
544
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
return
|
|
204
|
-
valid: true,
|
|
205
|
-
payload: event,
|
|
206
|
-
eventType: event.type,
|
|
207
|
-
webhookId: event.id
|
|
208
|
-
};
|
|
209
|
-
} catch {
|
|
210
|
-
return {
|
|
211
|
-
valid: false,
|
|
212
|
-
error: "Failed to parse webhook payload"
|
|
213
|
-
};
|
|
545
|
+
const parseResult = this.safeParseJson(payloadStr);
|
|
546
|
+
if (!parseResult.success) {
|
|
547
|
+
return this.createFailure("Failed to parse webhook payload");
|
|
214
548
|
}
|
|
549
|
+
const event = parseResult.data;
|
|
550
|
+
return this.createSuccess(event, {
|
|
551
|
+
eventType: event.type,
|
|
552
|
+
webhookId: event.id
|
|
553
|
+
});
|
|
215
554
|
}
|
|
216
555
|
parseEventType(payload) {
|
|
217
556
|
if (typeof payload === "object" && payload !== null && "type" in payload) {
|
|
@@ -219,9 +558,33 @@ class StripeProvider {
|
|
|
219
558
|
}
|
|
220
559
|
return;
|
|
221
560
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// src/providers/TwilioProvider.ts
|
|
564
|
+
class TwilioProvider extends BaseProvider {
|
|
565
|
+
name = "twilio";
|
|
566
|
+
baseUrl;
|
|
567
|
+
constructor(options = {}) {
|
|
568
|
+
super(options);
|
|
569
|
+
this.baseUrl = options.baseUrl;
|
|
570
|
+
}
|
|
571
|
+
async verify(payload, headers, secret) {
|
|
572
|
+
const signature = this.getHeader(headers, "x-twilio-signature");
|
|
573
|
+
if (!signature) {
|
|
574
|
+
return this.createFailure("Missing X-Twilio-Signature header");
|
|
575
|
+
}
|
|
576
|
+
const url = this.baseUrl ?? "";
|
|
577
|
+
const payloadStr = this.payloadToString(payload);
|
|
578
|
+
const params = new URLSearchParams(payloadStr);
|
|
579
|
+
const sortedParams = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}${value}`).join("");
|
|
580
|
+
const signaturePayload = url + sortedParams;
|
|
581
|
+
const expectedSignature = await computeHmacSha1Base64(signaturePayload, secret);
|
|
582
|
+
if (!timingSafeEqual(signature, expectedSignature)) {
|
|
583
|
+
return this.createFailure("Signature verification failed");
|
|
584
|
+
}
|
|
585
|
+
return this.createSuccess(Object.fromEntries(params), {
|
|
586
|
+
eventType: params.get("EventType") ?? undefined
|
|
587
|
+
});
|
|
225
588
|
}
|
|
226
589
|
}
|
|
227
590
|
|
|
@@ -230,12 +593,42 @@ class WebhookReceiver {
|
|
|
230
593
|
providers = new Map;
|
|
231
594
|
handlers = new Map;
|
|
232
595
|
globalHandlers = new Map;
|
|
596
|
+
store;
|
|
597
|
+
metrics = new NoopMetricsProvider;
|
|
598
|
+
tracer = new NoopTracer;
|
|
599
|
+
logger = new ConsoleEchoLogger;
|
|
600
|
+
keyRotationManager;
|
|
233
601
|
constructor() {
|
|
234
602
|
this.registerProviderType("generic", GenericProvider);
|
|
235
603
|
this.registerProviderType("stripe", StripeProvider);
|
|
236
604
|
this.registerProviderType("github", GitHubProvider);
|
|
605
|
+
this.registerProviderType("shopify", ShopifyProvider);
|
|
606
|
+
this.registerProviderType("twilio", TwilioProvider);
|
|
607
|
+
this.registerProviderType("slack", SlackProvider);
|
|
608
|
+
this.registerProviderType("paddle", PaddleProvider);
|
|
609
|
+
this.registerProviderType("linear", LinearProvider);
|
|
237
610
|
}
|
|
238
611
|
providerTypes = new Map;
|
|
612
|
+
setStore(store) {
|
|
613
|
+
this.store = store;
|
|
614
|
+
return this;
|
|
615
|
+
}
|
|
616
|
+
setMetrics(metrics) {
|
|
617
|
+
this.metrics = metrics;
|
|
618
|
+
return this;
|
|
619
|
+
}
|
|
620
|
+
setTracer(tracer) {
|
|
621
|
+
this.tracer = tracer;
|
|
622
|
+
return this;
|
|
623
|
+
}
|
|
624
|
+
setLogger(logger) {
|
|
625
|
+
this.logger = logger;
|
|
626
|
+
return this;
|
|
627
|
+
}
|
|
628
|
+
setKeyRotationManager(manager) {
|
|
629
|
+
this.keyRotationManager = manager;
|
|
630
|
+
return this;
|
|
631
|
+
}
|
|
239
632
|
registerProviderType(name, ProviderCls) {
|
|
240
633
|
this.providerTypes.set(name, ProviderCls);
|
|
241
634
|
return this;
|
|
@@ -250,11 +643,34 @@ class WebhookReceiver {
|
|
|
250
643
|
this.providers.set(name, { provider, secret });
|
|
251
644
|
return this;
|
|
252
645
|
}
|
|
646
|
+
registerProviderWithRotation(name, keys, options) {
|
|
647
|
+
if (!this.keyRotationManager) {
|
|
648
|
+
throw new Error("KeyRotationManager must be set before using key rotation");
|
|
649
|
+
}
|
|
650
|
+
const type = options?.type ?? name;
|
|
651
|
+
const ProviderClass = this.providerTypes.get(type);
|
|
652
|
+
if (!ProviderClass) {
|
|
653
|
+
throw new Error(`Unknown provider type: ${type}`);
|
|
654
|
+
}
|
|
655
|
+
this.keyRotationManager.registerKeys(name, keys);
|
|
656
|
+
const primaryKey = this.keyRotationManager.getPrimaryKey(name);
|
|
657
|
+
if (!primaryKey) {
|
|
658
|
+
throw new Error(`No primary key found for provider ${name}`);
|
|
659
|
+
}
|
|
660
|
+
const provider = new ProviderClass({ tolerance: options?.tolerance });
|
|
661
|
+
this.providers.set(name, { provider, secret: primaryKey.key });
|
|
662
|
+
return this;
|
|
663
|
+
}
|
|
253
664
|
on(providerName, eventType, handler) {
|
|
254
665
|
if (!this.handlers.has(providerName)) {
|
|
255
666
|
this.handlers.set(providerName, new Map);
|
|
256
667
|
}
|
|
257
668
|
const providerHandlers = this.handlers.get(providerName);
|
|
669
|
+
if (!providerHandlers) {
|
|
670
|
+
const newHandlers = new Map;
|
|
671
|
+
this.handlers.set(providerName, newHandlers);
|
|
672
|
+
return this.on(providerName, eventType, handler);
|
|
673
|
+
}
|
|
258
674
|
if (!providerHandlers.has(eventType)) {
|
|
259
675
|
providerHandlers.set(eventType, []);
|
|
260
676
|
}
|
|
@@ -268,48 +684,186 @@ class WebhookReceiver {
|
|
|
268
684
|
this.globalHandlers.get(providerName)?.push(handler);
|
|
269
685
|
return this;
|
|
270
686
|
}
|
|
271
|
-
async handle(providerName, body, headers) {
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
687
|
+
async handle(providerName, body, headers, context) {
|
|
688
|
+
const buffered = context?.get("bufferedRequest");
|
|
689
|
+
const actualBody = buffered?.rawBody ?? body;
|
|
690
|
+
const actualHeaders = buffered?.headers ?? headers;
|
|
691
|
+
return this.tracer.withSpan("echo.receive_webhook", async (span) => {
|
|
692
|
+
const startTime = performance.now();
|
|
693
|
+
const labels = { provider: providerName };
|
|
694
|
+
span.setAttributes({
|
|
695
|
+
"echo.provider": providerName,
|
|
696
|
+
"echo.direction": "incoming",
|
|
697
|
+
"echo.buffered": buffered !== undefined
|
|
698
|
+
});
|
|
699
|
+
this.logger.debug("Webhook received", {
|
|
700
|
+
component: "receiver",
|
|
701
|
+
provider: providerName,
|
|
702
|
+
buffered: buffered !== undefined
|
|
703
|
+
});
|
|
704
|
+
try {
|
|
705
|
+
const config = this.providers.get(providerName);
|
|
706
|
+
if (!config) {
|
|
707
|
+
const error = `Provider not registered: ${providerName}`;
|
|
708
|
+
this.logger.warn("Webhook provider not registered", {
|
|
709
|
+
component: "receiver",
|
|
710
|
+
provider: providerName
|
|
711
|
+
});
|
|
712
|
+
span.setStatus({ code: 2 /* ERROR */, message: error });
|
|
713
|
+
return {
|
|
714
|
+
valid: false,
|
|
715
|
+
error,
|
|
716
|
+
handled: false
|
|
717
|
+
};
|
|
302
718
|
}
|
|
719
|
+
const { provider, secret } = config;
|
|
720
|
+
span.addEvent("verification_start");
|
|
721
|
+
let result = {
|
|
722
|
+
valid: false,
|
|
723
|
+
error: "No verification performed"
|
|
724
|
+
};
|
|
725
|
+
if (this.keyRotationManager?.hasProvider(providerName)) {
|
|
726
|
+
const activeKeys = this.keyRotationManager.getActiveKeys(providerName);
|
|
727
|
+
let verified = false;
|
|
728
|
+
for (const keyEntry of activeKeys) {
|
|
729
|
+
const attemptResult = await provider.verify(actualBody, actualHeaders, keyEntry.key);
|
|
730
|
+
if (attemptResult.valid) {
|
|
731
|
+
result = attemptResult;
|
|
732
|
+
verified = true;
|
|
733
|
+
if (!keyEntry.isPrimary) {
|
|
734
|
+
this.logger.info("Webhook verified with non-primary key", {
|
|
735
|
+
component: "receiver",
|
|
736
|
+
provider: providerName,
|
|
737
|
+
keyVersion: keyEntry.version
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
break;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
if (!verified) {
|
|
744
|
+
result = {
|
|
745
|
+
valid: false,
|
|
746
|
+
error: "Signature verification failed with all active keys"
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
} else {
|
|
750
|
+
result = await provider.verify(actualBody, actualHeaders, secret);
|
|
751
|
+
}
|
|
752
|
+
if (!result.valid) {
|
|
753
|
+
span.setStatus({ code: 2 /* ERROR */, message: result.error });
|
|
754
|
+
span.setAttribute("echo.error", result.error ?? "unknown");
|
|
755
|
+
this.metrics.increment(EchoMetrics.INCOMING_VERIFICATION_FAILURES, {
|
|
756
|
+
provider: providerName,
|
|
757
|
+
error_type: this.categorizeError(result.error)
|
|
758
|
+
});
|
|
759
|
+
this.logger.warn("Webhook verification failed", {
|
|
760
|
+
component: "receiver",
|
|
761
|
+
provider: providerName,
|
|
762
|
+
error: result.error
|
|
763
|
+
});
|
|
764
|
+
return { ...result, handled: false };
|
|
765
|
+
}
|
|
766
|
+
span.addEvent("verification_success");
|
|
767
|
+
span.setAttributes({
|
|
768
|
+
"echo.event_type": result.eventType ?? "unknown",
|
|
769
|
+
"echo.webhook_id": result.webhookId ?? ""
|
|
770
|
+
});
|
|
771
|
+
this.logger.info("Webhook verified successfully", {
|
|
772
|
+
component: "receiver",
|
|
773
|
+
provider: providerName,
|
|
774
|
+
eventType: result.eventType,
|
|
775
|
+
webhookId: result.webhookId
|
|
776
|
+
});
|
|
777
|
+
labels.event_type = result.eventType;
|
|
778
|
+
labels.status = "success";
|
|
779
|
+
let eventId;
|
|
780
|
+
if (this.store) {
|
|
781
|
+
eventId = await this.store.saveIncomingEvent({
|
|
782
|
+
provider: providerName,
|
|
783
|
+
eventType: result.eventType ?? "unknown",
|
|
784
|
+
payload: result.payload,
|
|
785
|
+
headers: Object.fromEntries(Object.entries(actualHeaders).map(([k, v]) => [k, Array.isArray(v) ? v[0] : v])),
|
|
786
|
+
rawBody: typeof actualBody === "string" ? actualBody : actualBody.toString("utf-8"),
|
|
787
|
+
receivedAt: new Date,
|
|
788
|
+
status: "pending"
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
const event = {
|
|
792
|
+
provider: providerName,
|
|
793
|
+
type: result.eventType ?? "unknown",
|
|
794
|
+
payload: result.payload,
|
|
795
|
+
headers: actualHeaders,
|
|
796
|
+
rawBody: typeof actualBody === "string" ? actualBody : actualBody.toString("utf-8"),
|
|
797
|
+
receivedAt: new Date,
|
|
798
|
+
id: result.webhookId
|
|
799
|
+
};
|
|
800
|
+
try {
|
|
801
|
+
let handled = false;
|
|
802
|
+
let handlerCount = 0;
|
|
803
|
+
span.addEvent("handlers_start");
|
|
804
|
+
const providerHandlers = this.handlers.get(providerName);
|
|
805
|
+
if (providerHandlers) {
|
|
806
|
+
const eventHandlers = providerHandlers.get(event.type);
|
|
807
|
+
if (eventHandlers) {
|
|
808
|
+
for (const handler of eventHandlers) {
|
|
809
|
+
await handler(event);
|
|
810
|
+
handled = true;
|
|
811
|
+
handlerCount++;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
const globalHandlers = this.globalHandlers.get(providerName);
|
|
816
|
+
if (globalHandlers) {
|
|
817
|
+
for (const handler of globalHandlers) {
|
|
818
|
+
await handler(event);
|
|
819
|
+
handled = true;
|
|
820
|
+
handlerCount++;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
span.addEvent("handlers_complete", { handler_count: handlerCount });
|
|
824
|
+
if (this.store && eventId) {
|
|
825
|
+
await this.store.markProcessed(eventId);
|
|
826
|
+
}
|
|
827
|
+
this.logger.debug("Webhook processing complete", {
|
|
828
|
+
component: "receiver",
|
|
829
|
+
provider: providerName,
|
|
830
|
+
eventType: result.eventType,
|
|
831
|
+
handlersInvoked: handlerCount
|
|
832
|
+
});
|
|
833
|
+
span.setStatus({ code: 1 /* OK */ });
|
|
834
|
+
return { ...result, handled, eventId };
|
|
835
|
+
} catch (error) {
|
|
836
|
+
if (this.store && eventId) {
|
|
837
|
+
await this.store.markFailed(eventId, String(error));
|
|
838
|
+
}
|
|
839
|
+
throw error;
|
|
840
|
+
}
|
|
841
|
+
} catch (error) {
|
|
842
|
+
labels.status = "failure";
|
|
843
|
+
labels.error_type = error instanceof Error ? error.name : "unknown";
|
|
844
|
+
span.setStatus({ code: 2 /* ERROR */, message: String(error) });
|
|
845
|
+
throw error;
|
|
846
|
+
} finally {
|
|
847
|
+
const duration = (performance.now() - startTime) / 1000;
|
|
848
|
+
this.metrics.increment(EchoMetrics.INCOMING_TOTAL, labels);
|
|
849
|
+
this.metrics.histogram(EchoMetrics.INCOMING_DURATION, duration, labels);
|
|
303
850
|
}
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
categorizeError(error) {
|
|
854
|
+
if (!error) {
|
|
855
|
+
return "unknown";
|
|
304
856
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
857
|
+
if (error.includes("Missing")) {
|
|
858
|
+
return "missing_header";
|
|
859
|
+
}
|
|
860
|
+
if (error.includes("Signature")) {
|
|
861
|
+
return "signature_invalid";
|
|
862
|
+
}
|
|
863
|
+
if (error.includes("Timestamp")) {
|
|
864
|
+
return "timestamp_invalid";
|
|
311
865
|
}
|
|
312
|
-
return
|
|
866
|
+
return "other";
|
|
313
867
|
}
|
|
314
868
|
async verify(providerName, body, headers) {
|
|
315
869
|
const config = this.providers.get(providerName);
|
|
@@ -323,6 +877,220 @@ class WebhookReceiver {
|
|
|
323
877
|
}
|
|
324
878
|
}
|
|
325
879
|
|
|
880
|
+
// src/rotation/KeyRotationManager.ts
|
|
881
|
+
var DEFAULT_CONFIG2 = {
|
|
882
|
+
enabled: false,
|
|
883
|
+
autoCleanup: true,
|
|
884
|
+
gracePeriod: 24 * 60 * 60 * 1000,
|
|
885
|
+
keyProvider: async () => [],
|
|
886
|
+
onRotate: () => {}
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
class KeyRotationManager {
|
|
890
|
+
providerKeys = new Map;
|
|
891
|
+
cleanupTimer;
|
|
892
|
+
config;
|
|
893
|
+
constructor(config = {}) {
|
|
894
|
+
this.config = { ...DEFAULT_CONFIG2, ...config };
|
|
895
|
+
if (this.config.enabled && this.config.autoCleanup) {
|
|
896
|
+
this.startAutoCleanup();
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
registerKeys(providerName, keys) {
|
|
900
|
+
const primaryKeys = keys.filter((k) => k.isPrimary);
|
|
901
|
+
if (primaryKeys.length !== 1) {
|
|
902
|
+
throw new Error(`Provider ${providerName} must have exactly one primary key`);
|
|
903
|
+
}
|
|
904
|
+
const sorted = [...keys].sort((a, b) => b.activeFrom.getTime() - a.activeFrom.getTime());
|
|
905
|
+
this.providerKeys.set(providerName, sorted);
|
|
906
|
+
}
|
|
907
|
+
getActiveKeys(providerName) {
|
|
908
|
+
const keys = this.providerKeys.get(providerName) ?? [];
|
|
909
|
+
const now = new Date;
|
|
910
|
+
return keys.filter((key) => {
|
|
911
|
+
const isActive = key.activeFrom <= now;
|
|
912
|
+
const notExpired = !key.expiresAt || key.expiresAt > now;
|
|
913
|
+
return isActive && notExpired;
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
getPrimaryKey(providerName) {
|
|
917
|
+
const keys = this.getActiveKeys(providerName);
|
|
918
|
+
return keys.find((k) => k.isPrimary) ?? null;
|
|
919
|
+
}
|
|
920
|
+
async rotatePrimaryKey(providerName, newKey) {
|
|
921
|
+
const existingKeys = this.providerKeys.get(providerName) ?? [];
|
|
922
|
+
const updatedKeys = existingKeys.map((key) => ({
|
|
923
|
+
...key,
|
|
924
|
+
isPrimary: false,
|
|
925
|
+
expiresAt: key.isPrimary ? new Date(Date.now() + this.config.gracePeriod) : key.expiresAt
|
|
926
|
+
}));
|
|
927
|
+
const primaryKey = {
|
|
928
|
+
...newKey,
|
|
929
|
+
isPrimary: true
|
|
930
|
+
};
|
|
931
|
+
updatedKeys.unshift(primaryKey);
|
|
932
|
+
this.providerKeys.set(providerName, updatedKeys);
|
|
933
|
+
this.config.onRotate(providerName, primaryKey);
|
|
934
|
+
}
|
|
935
|
+
cleanupExpiredKeys() {
|
|
936
|
+
const now = new Date;
|
|
937
|
+
let cleaned = 0;
|
|
938
|
+
for (const [providerName, keys] of this.providerKeys.entries()) {
|
|
939
|
+
const active = keys.filter((key) => !key.expiresAt || key.expiresAt > now);
|
|
940
|
+
if (active.length !== keys.length) {
|
|
941
|
+
cleaned += keys.length - active.length;
|
|
942
|
+
this.providerKeys.set(providerName, active);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return cleaned;
|
|
946
|
+
}
|
|
947
|
+
startAutoCleanup() {
|
|
948
|
+
this.cleanupTimer = setInterval(() => {
|
|
949
|
+
const cleaned = this.cleanupExpiredKeys();
|
|
950
|
+
if (cleaned > 0) {
|
|
951
|
+
console.log(`[KeyRotationManager] Cleaned up ${cleaned} expired keys`);
|
|
952
|
+
}
|
|
953
|
+
}, 60 * 60 * 1000);
|
|
954
|
+
}
|
|
955
|
+
destroy() {
|
|
956
|
+
if (this.cleanupTimer) {
|
|
957
|
+
clearInterval(this.cleanupTimer);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
getAllProviderKeys() {
|
|
961
|
+
return new Map(this.providerKeys);
|
|
962
|
+
}
|
|
963
|
+
hasProvider(providerName) {
|
|
964
|
+
return this.providerKeys.has(providerName);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// src/resilience/CircuitBreaker.ts
|
|
969
|
+
var DEFAULT_CONFIG3 = {
|
|
970
|
+
enabled: true,
|
|
971
|
+
failureThreshold: 5,
|
|
972
|
+
successThreshold: 2,
|
|
973
|
+
windowSize: 60000,
|
|
974
|
+
openTimeout: 30000,
|
|
975
|
+
onOpen: () => {},
|
|
976
|
+
onHalfOpen: () => {},
|
|
977
|
+
onClose: () => {}
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
class CircuitBreaker {
|
|
981
|
+
name;
|
|
982
|
+
state = "CLOSED";
|
|
983
|
+
failures = 0;
|
|
984
|
+
successes = 0;
|
|
985
|
+
lastFailureAt;
|
|
986
|
+
lastSuccessAt;
|
|
987
|
+
openedAt;
|
|
988
|
+
halfOpenAttempts = 0;
|
|
989
|
+
config;
|
|
990
|
+
constructor(name, config = {}) {
|
|
991
|
+
this.name = name;
|
|
992
|
+
this.config = { ...DEFAULT_CONFIG3, ...config };
|
|
993
|
+
}
|
|
994
|
+
async execute(fn) {
|
|
995
|
+
if (!this.config.enabled) {
|
|
996
|
+
return await fn();
|
|
997
|
+
}
|
|
998
|
+
this.checkStateTransition();
|
|
999
|
+
if (this.state === "OPEN") {
|
|
1000
|
+
throw new Error(`Circuit breaker is OPEN for ${this.name}`);
|
|
1001
|
+
}
|
|
1002
|
+
try {
|
|
1003
|
+
const result = await fn();
|
|
1004
|
+
this.onSuccess();
|
|
1005
|
+
return result;
|
|
1006
|
+
} catch (error) {
|
|
1007
|
+
this.onFailure();
|
|
1008
|
+
throw error;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
onSuccess() {
|
|
1012
|
+
this.lastSuccessAt = new Date;
|
|
1013
|
+
this.successes++;
|
|
1014
|
+
if (this.state === "HALF_OPEN") {
|
|
1015
|
+
this.halfOpenAttempts++;
|
|
1016
|
+
if (this.halfOpenAttempts >= this.config.successThreshold) {
|
|
1017
|
+
this.transitionTo("CLOSED");
|
|
1018
|
+
this.reset();
|
|
1019
|
+
}
|
|
1020
|
+
} else if (this.state === "CLOSED") {
|
|
1021
|
+
this.failures = 0;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
onFailure() {
|
|
1025
|
+
this.lastFailureAt = new Date;
|
|
1026
|
+
this.failures++;
|
|
1027
|
+
if (this.state === "HALF_OPEN") {
|
|
1028
|
+
this.transitionTo("OPEN");
|
|
1029
|
+
this.openedAt = new Date;
|
|
1030
|
+
} else if (this.state === "CLOSED") {
|
|
1031
|
+
if (this.failures >= this.config.failureThreshold) {
|
|
1032
|
+
this.transitionTo("OPEN");
|
|
1033
|
+
this.openedAt = new Date;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
checkStateTransition() {
|
|
1038
|
+
if (this.state === "OPEN" && this.openedAt) {
|
|
1039
|
+
const elapsed = Date.now() - this.openedAt.getTime();
|
|
1040
|
+
if (elapsed >= this.config.openTimeout) {
|
|
1041
|
+
this.transitionTo("HALF_OPEN");
|
|
1042
|
+
this.halfOpenAttempts = 0;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
if (this.lastFailureAt) {
|
|
1046
|
+
const elapsed = Date.now() - this.lastFailureAt.getTime();
|
|
1047
|
+
if (elapsed >= this.config.windowSize) {
|
|
1048
|
+
this.failures = 0;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
transitionTo(newState) {
|
|
1053
|
+
this.state = newState;
|
|
1054
|
+
switch (newState) {
|
|
1055
|
+
case "OPEN":
|
|
1056
|
+
this.config.onOpen(this.name);
|
|
1057
|
+
break;
|
|
1058
|
+
case "HALF_OPEN":
|
|
1059
|
+
this.config.onHalfOpen(this.name);
|
|
1060
|
+
break;
|
|
1061
|
+
case "CLOSED":
|
|
1062
|
+
this.config.onClose(this.name);
|
|
1063
|
+
break;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
reset() {
|
|
1067
|
+
this.failures = 0;
|
|
1068
|
+
this.successes = 0;
|
|
1069
|
+
this.halfOpenAttempts = 0;
|
|
1070
|
+
this.openedAt = undefined;
|
|
1071
|
+
}
|
|
1072
|
+
getMetrics() {
|
|
1073
|
+
return {
|
|
1074
|
+
state: this.state,
|
|
1075
|
+
failures: this.failures,
|
|
1076
|
+
successes: this.successes,
|
|
1077
|
+
lastFailureAt: this.lastFailureAt,
|
|
1078
|
+
lastSuccessAt: this.lastSuccessAt,
|
|
1079
|
+
openedAt: this.openedAt
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
manualReset() {
|
|
1083
|
+
this.transitionTo("CLOSED");
|
|
1084
|
+
this.reset();
|
|
1085
|
+
}
|
|
1086
|
+
getState() {
|
|
1087
|
+
return this.state;
|
|
1088
|
+
}
|
|
1089
|
+
getName() {
|
|
1090
|
+
return this.name;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
326
1094
|
// src/send/WebhookDispatcher.ts
|
|
327
1095
|
var DEFAULT_RETRY_CONFIG = {
|
|
328
1096
|
maxAttempts: 3,
|
|
@@ -337,13 +1105,125 @@ class WebhookDispatcher {
|
|
|
337
1105
|
retryConfig;
|
|
338
1106
|
timeout;
|
|
339
1107
|
userAgent;
|
|
1108
|
+
dlq;
|
|
1109
|
+
metrics = new NoopMetricsProvider;
|
|
1110
|
+
tracer = new NoopTracer;
|
|
1111
|
+
logger;
|
|
1112
|
+
circuitBreakers = new Map;
|
|
1113
|
+
circuitBreakerConfig;
|
|
340
1114
|
constructor(config) {
|
|
341
1115
|
this.secret = config.secret;
|
|
342
1116
|
this.retryConfig = { ...DEFAULT_RETRY_CONFIG, ...config.retry };
|
|
343
1117
|
this.timeout = config.timeout ?? 30000;
|
|
344
1118
|
this.userAgent = config.userAgent ?? "Gravito-Echo/1.0";
|
|
1119
|
+
this.circuitBreakerConfig = config.circuitBreaker;
|
|
1120
|
+
}
|
|
1121
|
+
setDeadLetterQueue(dlq) {
|
|
1122
|
+
this.dlq = dlq;
|
|
1123
|
+
return this;
|
|
1124
|
+
}
|
|
1125
|
+
setMetrics(metrics) {
|
|
1126
|
+
this.metrics = metrics;
|
|
1127
|
+
return this;
|
|
1128
|
+
}
|
|
1129
|
+
setTracer(tracer) {
|
|
1130
|
+
this.tracer = tracer;
|
|
1131
|
+
return this;
|
|
1132
|
+
}
|
|
1133
|
+
setLogger(logger) {
|
|
1134
|
+
this.logger = logger;
|
|
1135
|
+
return this;
|
|
1136
|
+
}
|
|
1137
|
+
getCircuitBreaker(url) {
|
|
1138
|
+
if (!this.circuitBreakerConfig?.enabled) {
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
const host = new URL(url).host;
|
|
1142
|
+
if (!this.circuitBreakers.has(host)) {
|
|
1143
|
+
const breaker = new CircuitBreaker(host, {
|
|
1144
|
+
...this.circuitBreakerConfig,
|
|
1145
|
+
onOpen: (name) => {
|
|
1146
|
+
this.logger?.warn(`Circuit breaker OPEN for ${name}`, {
|
|
1147
|
+
component: "dispatcher",
|
|
1148
|
+
host: name
|
|
1149
|
+
});
|
|
1150
|
+
this.circuitBreakerConfig?.onOpen?.(name);
|
|
1151
|
+
},
|
|
1152
|
+
onHalfOpen: (name) => {
|
|
1153
|
+
this.logger?.info(`Circuit breaker HALF_OPEN for ${name}`, {
|
|
1154
|
+
component: "dispatcher",
|
|
1155
|
+
host: name
|
|
1156
|
+
});
|
|
1157
|
+
this.circuitBreakerConfig?.onHalfOpen?.(name);
|
|
1158
|
+
},
|
|
1159
|
+
onClose: (name) => {
|
|
1160
|
+
this.logger?.info(`Circuit breaker CLOSED for ${name}`, {
|
|
1161
|
+
component: "dispatcher",
|
|
1162
|
+
host: name
|
|
1163
|
+
});
|
|
1164
|
+
this.circuitBreakerConfig?.onClose?.(name);
|
|
1165
|
+
}
|
|
1166
|
+
});
|
|
1167
|
+
this.circuitBreakers.set(host, breaker);
|
|
1168
|
+
}
|
|
1169
|
+
return this.circuitBreakers.get(host);
|
|
1170
|
+
}
|
|
1171
|
+
getCircuitBreakerMetrics(url) {
|
|
1172
|
+
const host = new URL(url).host;
|
|
1173
|
+
const breaker = this.circuitBreakers.get(host);
|
|
1174
|
+
return breaker ? breaker.getMetrics() : null;
|
|
1175
|
+
}
|
|
1176
|
+
resetCircuitBreaker(url) {
|
|
1177
|
+
const host = new URL(url).host;
|
|
1178
|
+
const breaker = this.circuitBreakers.get(host);
|
|
1179
|
+
breaker?.manualReset();
|
|
345
1180
|
}
|
|
346
1181
|
async dispatch(payload) {
|
|
1182
|
+
return this.tracer.withSpan("echo.dispatch_webhook", async (span) => {
|
|
1183
|
+
const startTime = performance.now();
|
|
1184
|
+
const labels = { event_type: payload.event };
|
|
1185
|
+
span.setAttributes({
|
|
1186
|
+
"echo.direction": "outgoing",
|
|
1187
|
+
"echo.event": payload.event,
|
|
1188
|
+
"echo.url": payload.url,
|
|
1189
|
+
"http.method": "POST",
|
|
1190
|
+
"http.url": payload.url
|
|
1191
|
+
});
|
|
1192
|
+
const result = await this.dispatchInternal(payload);
|
|
1193
|
+
const duration = (performance.now() - startTime) / 1000;
|
|
1194
|
+
labels.status = result.success ? "success" : "failure";
|
|
1195
|
+
labels.status_code = result.statusCode?.toString();
|
|
1196
|
+
this.metrics.increment(EchoMetrics.OUTGOING_TOTAL, labels);
|
|
1197
|
+
this.metrics.histogram(EchoMetrics.OUTGOING_DURATION, duration, labels);
|
|
1198
|
+
if (result.attempt > 1) {
|
|
1199
|
+
this.metrics.increment(EchoMetrics.OUTGOING_RETRIES, {
|
|
1200
|
+
event_type: payload.event
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
span.setAttributes({
|
|
1204
|
+
"echo.success": result.success,
|
|
1205
|
+
"echo.attempt": result.attempt,
|
|
1206
|
+
"echo.duration_ms": result.duration
|
|
1207
|
+
});
|
|
1208
|
+
if (result.statusCode) {
|
|
1209
|
+
span.setAttribute("http.status_code", result.statusCode);
|
|
1210
|
+
}
|
|
1211
|
+
if (result.success) {
|
|
1212
|
+
span.setStatus({ code: 1 /* OK */ });
|
|
1213
|
+
} else {
|
|
1214
|
+
span.setStatus({
|
|
1215
|
+
code: 2 /* ERROR */,
|
|
1216
|
+
message: result.error
|
|
1217
|
+
});
|
|
1218
|
+
this.metrics.increment(EchoMetrics.OUTGOING_FAILURES, {
|
|
1219
|
+
event_type: payload.event,
|
|
1220
|
+
error_type: this.categorizeError(result)
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
return result;
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
async dispatchInternal(payload) {
|
|
347
1227
|
let lastResult = null;
|
|
348
1228
|
for (let attempt = 1;attempt <= this.retryConfig.maxAttempts; attempt++) {
|
|
349
1229
|
const result = await this.attemptDelivery(payload, attempt);
|
|
@@ -359,63 +1239,167 @@ class WebhookDispatcher {
|
|
|
359
1239
|
continue;
|
|
360
1240
|
}
|
|
361
1241
|
}
|
|
362
|
-
|
|
1242
|
+
break;
|
|
1243
|
+
}
|
|
1244
|
+
if (this.dlq && lastResult && !lastResult.success) {
|
|
1245
|
+
await this.dlq.enqueue({
|
|
1246
|
+
type: "outgoing",
|
|
1247
|
+
originalEvent: {
|
|
1248
|
+
url: payload.url,
|
|
1249
|
+
event: payload.event,
|
|
1250
|
+
payload: payload.data,
|
|
1251
|
+
createdAt: new Date,
|
|
1252
|
+
status: "failed",
|
|
1253
|
+
attempts: []
|
|
1254
|
+
},
|
|
1255
|
+
failureReason: lastResult.error ?? "Unknown error",
|
|
1256
|
+
failedAt: new Date,
|
|
1257
|
+
retryCount: lastResult.attempt
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
if (!lastResult) {
|
|
1261
|
+
throw new Error("No delivery attempts were made");
|
|
363
1262
|
}
|
|
364
1263
|
return lastResult;
|
|
365
1264
|
}
|
|
1265
|
+
async dispatchBatch(payloads, options = {}) {
|
|
1266
|
+
const concurrency = options.concurrency ?? 5;
|
|
1267
|
+
const stopOnFirstFailure = options.stopOnFirstFailure ?? false;
|
|
1268
|
+
const results = [];
|
|
1269
|
+
let succeeded = 0;
|
|
1270
|
+
let failed = 0;
|
|
1271
|
+
let stopped = false;
|
|
1272
|
+
for (let i = 0;i < payloads.length && !stopped; i += concurrency) {
|
|
1273
|
+
const chunk = payloads.slice(i, i + concurrency);
|
|
1274
|
+
const chunkResults = await Promise.all(chunk.map(async (payload) => {
|
|
1275
|
+
if (stopped) {
|
|
1276
|
+
return {
|
|
1277
|
+
payload,
|
|
1278
|
+
result: {
|
|
1279
|
+
success: false,
|
|
1280
|
+
attempt: 0,
|
|
1281
|
+
duration: 0,
|
|
1282
|
+
deliveredAt: new Date,
|
|
1283
|
+
error: "Batch dispatch stopped"
|
|
1284
|
+
}
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
const result = await this.dispatch(payload);
|
|
1288
|
+
if (result.success) {
|
|
1289
|
+
succeeded++;
|
|
1290
|
+
} else {
|
|
1291
|
+
failed++;
|
|
1292
|
+
if (stopOnFirstFailure) {
|
|
1293
|
+
stopped = true;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
return { payload, result };
|
|
1297
|
+
}));
|
|
1298
|
+
results.push(...chunkResults);
|
|
1299
|
+
}
|
|
1300
|
+
return {
|
|
1301
|
+
total: payloads.length,
|
|
1302
|
+
succeeded,
|
|
1303
|
+
failed,
|
|
1304
|
+
results
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
async retryFromDlq(id) {
|
|
1308
|
+
if (!this.dlq) {
|
|
1309
|
+
return null;
|
|
1310
|
+
}
|
|
1311
|
+
const events = await this.dlq.peek(100);
|
|
1312
|
+
const event = events.find((e) => e.id === id);
|
|
1313
|
+
if (!event || event.type !== "outgoing") {
|
|
1314
|
+
return null;
|
|
1315
|
+
}
|
|
1316
|
+
const outgoing = event.originalEvent;
|
|
1317
|
+
const result = await this.dispatch({
|
|
1318
|
+
url: outgoing.url,
|
|
1319
|
+
event: outgoing.event,
|
|
1320
|
+
data: outgoing.payload
|
|
1321
|
+
});
|
|
1322
|
+
if (result.success) {
|
|
1323
|
+
await this.dlq.dequeue(id);
|
|
1324
|
+
} else {
|
|
1325
|
+
event.retryCount++;
|
|
1326
|
+
event.lastRetryAt = new Date;
|
|
1327
|
+
}
|
|
1328
|
+
return result;
|
|
1329
|
+
}
|
|
366
1330
|
async attemptDelivery(payload, attempt) {
|
|
367
|
-
const
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const
|
|
372
|
-
id: webhookId,
|
|
373
|
-
type: payload.event,
|
|
374
|
-
timestamp,
|
|
375
|
-
data: payload.data
|
|
376
|
-
});
|
|
377
|
-
const signedPayload = `${timestamp}.${body}`;
|
|
378
|
-
const signature = await computeHmacSha256(signedPayload, this.secret);
|
|
379
|
-
const controller = new AbortController;
|
|
380
|
-
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
1331
|
+
const breaker = this.getCircuitBreaker(payload.url);
|
|
1332
|
+
const deliveryFn = async () => {
|
|
1333
|
+
const startTime = Date.now();
|
|
1334
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
1335
|
+
const webhookId = payload.id ?? crypto.randomUUID();
|
|
381
1336
|
try {
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
"X-Webhook-ID": webhookId,
|
|
388
|
-
"X-Webhook-Timestamp": String(timestamp),
|
|
389
|
-
"X-Webhook-Signature": `t=${timestamp},v1=${signature}`
|
|
390
|
-
},
|
|
391
|
-
body,
|
|
392
|
-
signal: controller.signal
|
|
1337
|
+
const body = JSON.stringify({
|
|
1338
|
+
id: webhookId,
|
|
1339
|
+
type: payload.event,
|
|
1340
|
+
timestamp,
|
|
1341
|
+
data: payload.data
|
|
393
1342
|
});
|
|
394
|
-
|
|
1343
|
+
const signedPayload = `${timestamp}.${body}`;
|
|
1344
|
+
const signature = await computeHmacSha256(signedPayload, this.secret);
|
|
1345
|
+
const controller = new AbortController;
|
|
1346
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
1347
|
+
try {
|
|
1348
|
+
const response = await fetch(payload.url, {
|
|
1349
|
+
method: "POST",
|
|
1350
|
+
headers: {
|
|
1351
|
+
"Content-Type": "application/json",
|
|
1352
|
+
"User-Agent": this.userAgent,
|
|
1353
|
+
"X-Webhook-ID": webhookId,
|
|
1354
|
+
"X-Webhook-Timestamp": String(timestamp),
|
|
1355
|
+
"X-Webhook-Signature": `t=${timestamp},v1=${signature}`
|
|
1356
|
+
},
|
|
1357
|
+
body,
|
|
1358
|
+
signal: controller.signal
|
|
1359
|
+
});
|
|
1360
|
+
clearTimeout(timeoutId);
|
|
1361
|
+
const duration = Date.now() - startTime;
|
|
1362
|
+
const responseBody = await response.text();
|
|
1363
|
+
return {
|
|
1364
|
+
success: response.ok,
|
|
1365
|
+
statusCode: response.status,
|
|
1366
|
+
body: responseBody,
|
|
1367
|
+
attempt,
|
|
1368
|
+
duration,
|
|
1369
|
+
deliveredAt: new Date,
|
|
1370
|
+
error: response.ok ? undefined : `HTTP ${response.status}`
|
|
1371
|
+
};
|
|
1372
|
+
} finally {
|
|
1373
|
+
clearTimeout(timeoutId);
|
|
1374
|
+
}
|
|
1375
|
+
} catch (error) {
|
|
395
1376
|
const duration = Date.now() - startTime;
|
|
396
|
-
const responseBody = await response.text();
|
|
397
1377
|
return {
|
|
398
|
-
success:
|
|
399
|
-
statusCode: response.status,
|
|
400
|
-
body: responseBody,
|
|
1378
|
+
success: false,
|
|
401
1379
|
attempt,
|
|
402
1380
|
duration,
|
|
403
1381
|
deliveredAt: new Date,
|
|
404
|
-
error:
|
|
1382
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
405
1383
|
};
|
|
406
|
-
} finally {
|
|
407
|
-
clearTimeout(timeoutId);
|
|
408
1384
|
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
1385
|
+
};
|
|
1386
|
+
if (breaker) {
|
|
1387
|
+
try {
|
|
1388
|
+
return await breaker.execute(deliveryFn);
|
|
1389
|
+
} catch (error) {
|
|
1390
|
+
if (error instanceof Error && error.message.includes("Circuit breaker is OPEN")) {
|
|
1391
|
+
return {
|
|
1392
|
+
success: false,
|
|
1393
|
+
attempt,
|
|
1394
|
+
duration: 0,
|
|
1395
|
+
deliveredAt: new Date,
|
|
1396
|
+
error: error.message
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
throw error;
|
|
1400
|
+
}
|
|
418
1401
|
}
|
|
1402
|
+
return await deliveryFn();
|
|
419
1403
|
}
|
|
420
1404
|
shouldRetry(result) {
|
|
421
1405
|
if (!result.statusCode) {
|
|
@@ -427,6 +1411,18 @@ class WebhookDispatcher {
|
|
|
427
1411
|
const delay = this.retryConfig.initialDelay * this.retryConfig.backoffMultiplier ** (attempt - 1);
|
|
428
1412
|
return Math.min(delay, this.retryConfig.maxDelay);
|
|
429
1413
|
}
|
|
1414
|
+
categorizeError(result) {
|
|
1415
|
+
if (!result.statusCode) {
|
|
1416
|
+
return "network_error";
|
|
1417
|
+
}
|
|
1418
|
+
if (result.statusCode >= 500) {
|
|
1419
|
+
return "server_error";
|
|
1420
|
+
}
|
|
1421
|
+
if (result.statusCode >= 400) {
|
|
1422
|
+
return "client_error";
|
|
1423
|
+
}
|
|
1424
|
+
return "other";
|
|
1425
|
+
}
|
|
430
1426
|
sleep(ms) {
|
|
431
1427
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
432
1428
|
}
|
|
@@ -434,31 +1430,73 @@ class WebhookDispatcher {
|
|
|
434
1430
|
|
|
435
1431
|
// src/OrbitEcho.ts
|
|
436
1432
|
class OrbitEcho {
|
|
437
|
-
static config = { singleton: true };
|
|
438
1433
|
receiver;
|
|
439
1434
|
dispatcher;
|
|
440
1435
|
echoConfig;
|
|
1436
|
+
keyRotationManager;
|
|
441
1437
|
constructor(config = {}) {
|
|
442
1438
|
this.echoConfig = config;
|
|
443
1439
|
this.receiver = new WebhookReceiver;
|
|
1440
|
+
if (config.keyRotation?.enabled) {
|
|
1441
|
+
this.keyRotationManager = new KeyRotationManager(config.keyRotation);
|
|
1442
|
+
this.receiver.setKeyRotationManager(this.keyRotationManager);
|
|
1443
|
+
}
|
|
444
1444
|
if (config.providers) {
|
|
445
1445
|
for (const [name, providerConfig] of Object.entries(config.providers)) {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
1446
|
+
const rotationConfig = providerConfig;
|
|
1447
|
+
if (rotationConfig.keys && rotationConfig.keys.length > 0 && this.keyRotationManager) {
|
|
1448
|
+
this.receiver.registerProviderWithRotation(name, rotationConfig.keys, {
|
|
1449
|
+
type: providerConfig.name,
|
|
1450
|
+
tolerance: providerConfig.tolerance
|
|
1451
|
+
});
|
|
1452
|
+
} else {
|
|
1453
|
+
this.receiver.registerProvider(name, providerConfig.secret, {
|
|
1454
|
+
type: providerConfig.name,
|
|
1455
|
+
tolerance: providerConfig.tolerance
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
450
1458
|
}
|
|
451
1459
|
}
|
|
452
1460
|
if (config.dispatcher) {
|
|
453
1461
|
this.dispatcher = new WebhookDispatcher(config.dispatcher);
|
|
1462
|
+
if (config.deadLetterQueue) {
|
|
1463
|
+
this.dispatcher.setDeadLetterQueue(config.deadLetterQueue);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
if (config.store) {
|
|
1467
|
+
this.receiver.setStore(config.store);
|
|
1468
|
+
}
|
|
1469
|
+
if (config.observability) {
|
|
1470
|
+
const { metrics, tracer, logger } = config.observability;
|
|
1471
|
+
if (metrics) {
|
|
1472
|
+
this.receiver.setMetrics(metrics);
|
|
1473
|
+
this.dispatcher?.setMetrics(metrics);
|
|
1474
|
+
}
|
|
1475
|
+
if (tracer) {
|
|
1476
|
+
this.receiver.setTracer(tracer);
|
|
1477
|
+
this.dispatcher?.setTracer(tracer);
|
|
1478
|
+
}
|
|
1479
|
+
if (logger) {
|
|
1480
|
+
this.receiver.setLogger(logger);
|
|
1481
|
+
}
|
|
454
1482
|
}
|
|
455
1483
|
}
|
|
456
1484
|
install(core) {
|
|
1485
|
+
if (this.echoConfig.requestBuffer?.enabled !== false) {
|
|
1486
|
+
const bufferMiddleware = createRequestBufferMiddleware(this.echoConfig.requestBuffer);
|
|
1487
|
+
core.adapter.use("*", bufferMiddleware);
|
|
1488
|
+
core.logger.info("[OrbitEcho] Request buffer middleware installed");
|
|
1489
|
+
}
|
|
457
1490
|
core.container.instance("echo", this);
|
|
458
1491
|
core.container.instance("echo.receiver", this.receiver);
|
|
459
1492
|
if (this.dispatcher) {
|
|
460
1493
|
core.container.instance("echo.dispatcher", this.dispatcher);
|
|
461
1494
|
}
|
|
1495
|
+
core.adapter.use("*", async (c, next) => {
|
|
1496
|
+
c.set("echo", this);
|
|
1497
|
+
return await next();
|
|
1498
|
+
});
|
|
1499
|
+
core.logger.info("[OrbitEcho] Webhook receiver and dispatcher registered");
|
|
462
1500
|
}
|
|
463
1501
|
getReceiver() {
|
|
464
1502
|
return this.receiver;
|
|
@@ -469,19 +1507,123 @@ class OrbitEcho {
|
|
|
469
1507
|
getConfig() {
|
|
470
1508
|
return this.echoConfig;
|
|
471
1509
|
}
|
|
1510
|
+
getKeyRotationManager() {
|
|
1511
|
+
return this.keyRotationManager;
|
|
1512
|
+
}
|
|
1513
|
+
async rotateProviderKey(providerName, newKey) {
|
|
1514
|
+
if (!this.keyRotationManager) {
|
|
1515
|
+
throw new Error("Key rotation is not enabled");
|
|
1516
|
+
}
|
|
1517
|
+
await this.keyRotationManager.rotatePrimaryKey(providerName, newKey);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
// src/replay/WebhookReplayService.ts
|
|
1521
|
+
class WebhookReplayService {
|
|
1522
|
+
store;
|
|
1523
|
+
dispatcher;
|
|
1524
|
+
constructor(store, dispatcher) {
|
|
1525
|
+
this.store = store;
|
|
1526
|
+
this.dispatcher = dispatcher;
|
|
1527
|
+
}
|
|
1528
|
+
async replay(options) {
|
|
1529
|
+
const events = await this.store.queryEvents({
|
|
1530
|
+
direction: "outgoing",
|
|
1531
|
+
provider: options.provider,
|
|
1532
|
+
eventType: options.eventType,
|
|
1533
|
+
from: options.timeRange?.from,
|
|
1534
|
+
to: options.timeRange?.to
|
|
1535
|
+
});
|
|
1536
|
+
const targetEvents = options.eventIds ? events.filter((e) => options.eventIds?.includes(e.id ?? "")) : events;
|
|
1537
|
+
const result = {
|
|
1538
|
+
total: targetEvents.length,
|
|
1539
|
+
replayed: 0,
|
|
1540
|
+
skipped: 0,
|
|
1541
|
+
failed: 0,
|
|
1542
|
+
events: []
|
|
1543
|
+
};
|
|
1544
|
+
for (const event of targetEvents) {
|
|
1545
|
+
if (event.direction !== "outgoing") {
|
|
1546
|
+
result.skipped++;
|
|
1547
|
+
result.events.push({
|
|
1548
|
+
eventId: event.id ?? "unknown",
|
|
1549
|
+
status: "skipped",
|
|
1550
|
+
error: "Not an outgoing event"
|
|
1551
|
+
});
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1554
|
+
const outgoing = event;
|
|
1555
|
+
if (options.dryRun) {
|
|
1556
|
+
result.replayed++;
|
|
1557
|
+
result.events.push({
|
|
1558
|
+
eventId: event.id ?? "unknown",
|
|
1559
|
+
status: "replayed"
|
|
1560
|
+
});
|
|
1561
|
+
continue;
|
|
1562
|
+
}
|
|
1563
|
+
try {
|
|
1564
|
+
const dispatchResult = await this.dispatcher.dispatch({
|
|
1565
|
+
url: options.targetUrl ?? outgoing.url,
|
|
1566
|
+
event: outgoing.event,
|
|
1567
|
+
data: outgoing.payload
|
|
1568
|
+
});
|
|
1569
|
+
if (dispatchResult.success) {
|
|
1570
|
+
result.replayed++;
|
|
1571
|
+
result.events.push({
|
|
1572
|
+
eventId: event.id ?? "unknown",
|
|
1573
|
+
status: "replayed",
|
|
1574
|
+
result: dispatchResult
|
|
1575
|
+
});
|
|
1576
|
+
} else {
|
|
1577
|
+
result.failed++;
|
|
1578
|
+
result.events.push({
|
|
1579
|
+
eventId: event.id ?? "unknown",
|
|
1580
|
+
status: "failed",
|
|
1581
|
+
result: dispatchResult,
|
|
1582
|
+
error: dispatchResult.error
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
} catch (error) {
|
|
1586
|
+
result.failed++;
|
|
1587
|
+
result.events.push({
|
|
1588
|
+
eventId: event.id ?? "unknown",
|
|
1589
|
+
status: "failed",
|
|
1590
|
+
error: String(error)
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
return result;
|
|
1595
|
+
}
|
|
472
1596
|
}
|
|
473
1597
|
export {
|
|
474
1598
|
validateTimestamp,
|
|
475
1599
|
timingSafeEqual,
|
|
476
1600
|
parseStripeSignature,
|
|
1601
|
+
createRequestBufferMiddleware,
|
|
477
1602
|
computeHmacSha256,
|
|
478
1603
|
computeHmacSha1,
|
|
1604
|
+
WebhookReplayService,
|
|
479
1605
|
WebhookReceiver,
|
|
480
|
-
|
|
1606
|
+
TwilioProvider,
|
|
481
1607
|
StripeProvider,
|
|
1608
|
+
SpanStatusCode,
|
|
1609
|
+
SlackProvider,
|
|
1610
|
+
ShopifyProvider,
|
|
1611
|
+
RequestBufferMiddleware,
|
|
1612
|
+
PrometheusMetricsProvider,
|
|
1613
|
+
PaddleProvider,
|
|
482
1614
|
OrbitEcho,
|
|
1615
|
+
NoopTracer,
|
|
1616
|
+
NoopSpan,
|
|
1617
|
+
NoopMetricsProvider,
|
|
1618
|
+
MemoryDeadLetterQueue,
|
|
1619
|
+
LinearProvider,
|
|
1620
|
+
KeyRotationManager,
|
|
483
1621
|
GitHubProvider,
|
|
484
|
-
GenericProvider
|
|
1622
|
+
GenericProvider,
|
|
1623
|
+
EchoMetrics,
|
|
1624
|
+
ConsoleEchoLogger,
|
|
1625
|
+
CircuitBreaker,
|
|
1626
|
+
BaseProvider
|
|
485
1627
|
};
|
|
486
1628
|
|
|
487
|
-
//# debugId=
|
|
1629
|
+
//# debugId=58FA12C7769E147864756E2164756E21
|