@hogsend/engine 0.21.1 → 0.22.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/package.json +7 -7
- package/src/app.ts +37 -30
- package/src/connectors/define-connector.ts +205 -0
- package/src/connectors/presets/index.ts +31 -0
- package/src/connectors/registry-singleton.ts +79 -0
- package/src/container.ts +73 -0
- package/src/env.ts +5 -0
- package/src/index.ts +56 -0
- package/src/lib/connector-link-codes.ts +218 -0
- package/src/lib/connector-state.ts +114 -0
- package/src/lib/contacts.ts +121 -8
- package/src/lib/discord-gateway-heartbeat.ts +164 -0
- package/src/lib/ingestion.ts +6 -0
- package/src/lib/provider-credentials.ts +11 -0
- package/src/routes/admin/connectors.ts +466 -0
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/connectors/index.ts +279 -0
- package/src/routes/index.ts +17 -4
- package/src/routes/webhooks/index.ts +3 -3
- package/src/routes/webhooks/sources.ts +20 -10
- package/src/webhook-sources/define-webhook-source.ts +44 -39
- package/src/webhook-sources/verify.ts +4 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi";
|
|
2
|
+
import type { AppEnv } from "../../app.js";
|
|
3
|
+
import type { DefinedConnector } from "../../connectors/define-connector.js";
|
|
4
|
+
import { getConnectorRegistry } from "../../connectors/registry-singleton.js";
|
|
5
|
+
import { verifyConnectorState } from "../../lib/connector-state.js";
|
|
6
|
+
import { headersToRecord } from "../../lib/headers.js";
|
|
7
|
+
import { ingestEvent } from "../../lib/ingestion.js";
|
|
8
|
+
import type { Logger } from "../../lib/logger.js";
|
|
9
|
+
import { getRedisIfConnected } from "../../lib/redis.js";
|
|
10
|
+
import { clientIpKey, createRateLimit } from "../../middleware/rate-limit.js";
|
|
11
|
+
import { safeEqual } from "../../webhook-sources/verify.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Diagnose a registered-but-bare connector: a `transport: "gateway"` connector
|
|
15
|
+
* present in the registry that ships NO `handlers` cannot answer the generic
|
|
16
|
+
* oauth/interactions dispatch and would otherwise 404 silently (looking like an
|
|
17
|
+
* unknown connector). Log a warning so a misconfigured bare-connector
|
|
18
|
+
* registration (the bare `discordConnector` vs. `createDiscordConnector(...)`)
|
|
19
|
+
* is diagnosable. A genuinely unknown id (no connector) stays a quiet 404.
|
|
20
|
+
*/
|
|
21
|
+
function warnBareGatewayConnector(
|
|
22
|
+
logger: Logger,
|
|
23
|
+
id: string,
|
|
24
|
+
connector: DefinedConnector | undefined,
|
|
25
|
+
surface: "oauthCallback" | "interactions",
|
|
26
|
+
): void {
|
|
27
|
+
if (connector && (connector.meta.transport ?? "webhook") === "gateway") {
|
|
28
|
+
logger.warn(
|
|
29
|
+
"connector registered without handlers — gateway connector cannot " +
|
|
30
|
+
`serve ${surface}; register the connect-ready factory (e.g. ` +
|
|
31
|
+
"createDiscordConnector(config)) instead of the bare const",
|
|
32
|
+
{ connectorId: id, surface },
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The generic connector dispatch surface: oauth/interactions/ingress. These
|
|
39
|
+
* routes are UNAUTHENTICATED at the api-key layer BY DESIGN — each
|
|
40
|
+
* self-authenticates (oauth `state` + code exchange, ed25519 interaction
|
|
41
|
+
* signatures, the shared ingress secret). Do NOT add a blanket api-key guard.
|
|
42
|
+
*
|
|
43
|
+
* Because they are public + self-verifying, an attacker can otherwise hammer
|
|
44
|
+
* them to force an ed25519 verify / constant-time secret compare per request
|
|
45
|
+
* (CPU amplification). So we layer an IP-keyed sliding-window rate limit on the
|
|
46
|
+
* whole `/v1/connectors/*` subtree (distinct prefix → isolated budget, mirroring
|
|
47
|
+
* the sign-up throttle in app.ts).
|
|
48
|
+
*/
|
|
49
|
+
export function registerConnectorRoutes(app: OpenAPIHono<AppEnv>) {
|
|
50
|
+
const connectorRateLimit = createRateLimit({
|
|
51
|
+
prefix: "ratelimit:connectors",
|
|
52
|
+
windowMs: 60_000,
|
|
53
|
+
max: 60,
|
|
54
|
+
keyFn: clientIpKey,
|
|
55
|
+
});
|
|
56
|
+
// `/ingress` is authed by the shared ingress secret (hit once per event by the
|
|
57
|
+
// trusted gateway worker); `/interactions` is authed by Discord's ed25519
|
|
58
|
+
// signature + a timestamp replay window. BOTH arrive from a SMALL set of source
|
|
59
|
+
// IPs (the worker behind a tunnel; Discord's interaction egress), so per-IP
|
|
60
|
+
// keying would collapse a whole community onto ONE 60/min bucket and 429 the
|
|
61
|
+
// very /link & /verify loop we ship (Discord renders a 429 as "the application
|
|
62
|
+
// did not respond"). The IP limit is sized for the public, self-verifying OAuth
|
|
63
|
+
// callback — keep it there only; the ed25519 verify + replay window already
|
|
64
|
+
// gate /interactions, and the constant-time secret compare gates /ingress.
|
|
65
|
+
app.use("/v1/connectors/*", async (c, next) => {
|
|
66
|
+
const p = c.req.path;
|
|
67
|
+
if (p.endsWith("/ingress") || p.endsWith("/interactions")) return next();
|
|
68
|
+
return connectorRateLimit(c, next);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// --- OAuth callback: GET|POST /v1/connectors/:id/oauth/callback -----------
|
|
72
|
+
// GET handles the browser redirect-URI return (most OAuth flows); a POST
|
|
73
|
+
// variant is mounted too for connectors that prefer it. Both dispatch to
|
|
74
|
+
// handlers.oauthCallback.
|
|
75
|
+
for (const method of ["get", "post"] as const) {
|
|
76
|
+
app.openapi(
|
|
77
|
+
createRoute({
|
|
78
|
+
method,
|
|
79
|
+
path: "/v1/connectors/{id}/oauth/callback",
|
|
80
|
+
tags: ["Connectors"],
|
|
81
|
+
request: { params: z.object({ id: z.string() }) },
|
|
82
|
+
responses: {
|
|
83
|
+
200: { description: "OAuth handled" },
|
|
84
|
+
302: { description: "Redirect" },
|
|
85
|
+
400: { description: "Missing / invalid / expired state" },
|
|
86
|
+
404: { description: "Unknown connector / no oauth handler" },
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
async (c) => {
|
|
90
|
+
const { id } = c.req.valid("param");
|
|
91
|
+
const { db, logger, env } = c.get("container");
|
|
92
|
+
const connector = getConnectorRegistry().get(id);
|
|
93
|
+
if (!connector?.handlers?.oauthCallback) {
|
|
94
|
+
warnBareGatewayConnector(logger, id, connector, "oauthCallback");
|
|
95
|
+
return c.json({ error: "Unknown connector" }, 404);
|
|
96
|
+
}
|
|
97
|
+
const url = new URL(c.req.url);
|
|
98
|
+
const query = Object.fromEntries(url.searchParams.entries());
|
|
99
|
+
|
|
100
|
+
// The ENGINE owns CSRF state GENERICALLY: this callback lands
|
|
101
|
+
// UNAUTHENTICATED, so a forged callback (login-CSRF / grafting an
|
|
102
|
+
// identity onto an arbitrary contact) is only prevented by a
|
|
103
|
+
// server-minted, server-verified signed `state`. Verify BEFORE
|
|
104
|
+
// dispatching — a missing/invalid/expired state never reaches the
|
|
105
|
+
// connector handler (no code exchange, no contact binding).
|
|
106
|
+
const stateCheck = verifyConnectorState(
|
|
107
|
+
query.state ?? "",
|
|
108
|
+
env.BETTER_AUTH_SECRET,
|
|
109
|
+
);
|
|
110
|
+
if (!stateCheck.valid || !stateCheck.intent) {
|
|
111
|
+
logger.warn("connector oauth callback: invalid state", {
|
|
112
|
+
connectorId: id,
|
|
113
|
+
reason: stateCheck.reason,
|
|
114
|
+
});
|
|
115
|
+
return c.json({ error: "Invalid state" }, 400);
|
|
116
|
+
}
|
|
117
|
+
// Bind the state to THIS connector: `BETTER_AUTH_SECRET` signs every
|
|
118
|
+
// connector's state, so a state minted for connector A is
|
|
119
|
+
// signature-valid here too. Reject a state whose `connectorId` does not
|
|
120
|
+
// match this route's `:id` (cross-connector state replay).
|
|
121
|
+
if (stateCheck.intent.connectorId !== id) {
|
|
122
|
+
logger.warn("connector oauth callback: state connector mismatch", {
|
|
123
|
+
routeConnectorId: id,
|
|
124
|
+
stateConnectorId: stateCheck.intent.connectorId,
|
|
125
|
+
});
|
|
126
|
+
return c.json({ error: "Invalid state" }, 400);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// SINGLE-USE: the signed state is otherwise TTL-replayable — a captured
|
|
130
|
+
// callback URL works until `exp`. Burn the per-mint nonce on first use:
|
|
131
|
+
// a `SET … NX EX` succeeds exactly once, so a second callback carrying the
|
|
132
|
+
// same nonce (NX fails → `null`) is rejected as a replay. The TTL matches
|
|
133
|
+
// the max state window (900s) so the used-marker outlives any valid state.
|
|
134
|
+
// Redis-less deploys (self-host without redis, tests) fall back to
|
|
135
|
+
// TTL-only single-validity — we never block a callback on a cache miss.
|
|
136
|
+
const redis = getRedisIfConnected();
|
|
137
|
+
if (redis) {
|
|
138
|
+
const usedKey = `connector:state:used:${stateCheck.intent.nonce}`;
|
|
139
|
+
const claimed = await redis.set(usedKey, "1", "EX", 900, "NX");
|
|
140
|
+
if (claimed !== "OK") {
|
|
141
|
+
logger.warn("connector oauth callback: state replay rejected", {
|
|
142
|
+
connectorId: id,
|
|
143
|
+
});
|
|
144
|
+
return c.json({ error: "Invalid state" }, 400);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let body: unknown;
|
|
149
|
+
try {
|
|
150
|
+
body = method === "post" ? await c.req.json() : undefined;
|
|
151
|
+
} catch {
|
|
152
|
+
body = undefined;
|
|
153
|
+
}
|
|
154
|
+
const result = await connector.handlers.oauthCallback({
|
|
155
|
+
query,
|
|
156
|
+
body,
|
|
157
|
+
state: stateCheck.intent,
|
|
158
|
+
ctx: { db, logger, env, apiPublicUrl: env.API_PUBLIC_URL },
|
|
159
|
+
});
|
|
160
|
+
if (result.kind === "redirect") {
|
|
161
|
+
return c.redirect(result.location, 302);
|
|
162
|
+
}
|
|
163
|
+
if (result.kind === "html") {
|
|
164
|
+
// Serve a self-contained branded page as text/html (a raw HTML
|
|
165
|
+
// string through the `json` kind would be JSON-quoted in the browser).
|
|
166
|
+
return c.html(result.body, result.status === 400 ? 400 : 200);
|
|
167
|
+
}
|
|
168
|
+
// `c.json`'s typed status union only accepts the route's declared
|
|
169
|
+
// literals — branch on the concrete status instead of casting a runtime
|
|
170
|
+
// number to a literal.
|
|
171
|
+
if (result.status === 404) {
|
|
172
|
+
return c.json(result.body as object, 404);
|
|
173
|
+
}
|
|
174
|
+
return c.json(result.body as object, 200);
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- Interactions: POST /v1/connectors/:id/interactions -------------------
|
|
180
|
+
app.openapi(
|
|
181
|
+
createRoute({
|
|
182
|
+
method: "post",
|
|
183
|
+
path: "/v1/connectors/{id}/interactions",
|
|
184
|
+
tags: ["Connectors"],
|
|
185
|
+
request: { params: z.object({ id: z.string() }) },
|
|
186
|
+
responses: {
|
|
187
|
+
200: { description: "Interaction acknowledged / ingested" },
|
|
188
|
+
401: { description: "Bad platform signature" },
|
|
189
|
+
404: { description: "Unknown connector / no interactions handler" },
|
|
190
|
+
},
|
|
191
|
+
}),
|
|
192
|
+
async (c) => {
|
|
193
|
+
const { id } = c.req.valid("param");
|
|
194
|
+
const { db, logger, env, registry, hatchet } = c.get("container");
|
|
195
|
+
const connector = getConnectorRegistry().get(id);
|
|
196
|
+
if (!connector?.handlers?.interactions) {
|
|
197
|
+
warnBareGatewayConnector(logger, id, connector, "interactions");
|
|
198
|
+
return c.json({ error: "Unknown connector" }, 404);
|
|
199
|
+
}
|
|
200
|
+
const rawBody = await c.req.text(); // EXACT bytes — ed25519 covers them
|
|
201
|
+
const headers = headersToRecord(c.req.raw.headers);
|
|
202
|
+
const result = await connector.handlers.interactions({
|
|
203
|
+
rawBody,
|
|
204
|
+
headers,
|
|
205
|
+
ctx: { db, logger, env, apiPublicUrl: env.API_PUBLIC_URL },
|
|
206
|
+
});
|
|
207
|
+
if (result.kind === "unauthorized") {
|
|
208
|
+
return c.json({ error: "Invalid signature" }, 401);
|
|
209
|
+
}
|
|
210
|
+
if (result.kind === "ingest") {
|
|
211
|
+
await ingestEvent({
|
|
212
|
+
db,
|
|
213
|
+
registry,
|
|
214
|
+
hatchet,
|
|
215
|
+
logger,
|
|
216
|
+
event: result.event,
|
|
217
|
+
});
|
|
218
|
+
return c.json({ ok: true }, 200);
|
|
219
|
+
}
|
|
220
|
+
// `kind: "ack"` — a non-event handshake the connector already answered.
|
|
221
|
+
return c.json((result.body ?? { ok: true }) as object, 200);
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// --- Gateway ingress: POST /v1/connectors/:id/ingress ---------------------
|
|
226
|
+
// The long-lived gateway worker POSTs raw platform events here behind the
|
|
227
|
+
// shared internal secret; the route runs the connector's transform so ALL
|
|
228
|
+
// transform logic stays in the connector and the worker is dumb.
|
|
229
|
+
app.openapi(
|
|
230
|
+
createRoute({
|
|
231
|
+
method: "post",
|
|
232
|
+
path: "/v1/connectors/{id}/ingress",
|
|
233
|
+
tags: ["Connectors"],
|
|
234
|
+
request: { params: z.object({ id: z.string() }) },
|
|
235
|
+
responses: {
|
|
236
|
+
200: { description: "Ingested / skipped" },
|
|
237
|
+
401: { description: "Bad internal secret" },
|
|
238
|
+
404: { description: "Unknown gateway connector" },
|
|
239
|
+
},
|
|
240
|
+
}),
|
|
241
|
+
async (c) => {
|
|
242
|
+
const { id } = c.req.valid("param");
|
|
243
|
+
const connector = getConnectorRegistry().get(id);
|
|
244
|
+
if (!connector || (connector.meta.transport ?? "webhook") !== "gateway") {
|
|
245
|
+
return c.json({ error: "Unknown gateway connector" }, 404);
|
|
246
|
+
}
|
|
247
|
+
const { db, logger, env, registry, hatchet } = c.get("container");
|
|
248
|
+
const expected = env.CONNECTOR_INGRESS_SECRET;
|
|
249
|
+
const provided = c.req.header("x-hogsend-ingress-secret");
|
|
250
|
+
// Fail CLOSED: an unconfigured ingress secret cannot be relayed into.
|
|
251
|
+
// `safeEqual` length-guards before the constant-time compare, so a length
|
|
252
|
+
// mismatch returns false rather than throwing.
|
|
253
|
+
if (!expected || !provided || !safeEqual(provided, expected)) {
|
|
254
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
255
|
+
}
|
|
256
|
+
const payload = await c.req.json();
|
|
257
|
+
const event = await connector.transform(payload, {
|
|
258
|
+
db,
|
|
259
|
+
logger,
|
|
260
|
+
transport: "gateway",
|
|
261
|
+
});
|
|
262
|
+
if (!event) return c.json({ ok: true, skipped: true }, 200);
|
|
263
|
+
const result = await ingestEvent({
|
|
264
|
+
db,
|
|
265
|
+
registry,
|
|
266
|
+
hatchet,
|
|
267
|
+
logger,
|
|
268
|
+
event,
|
|
269
|
+
});
|
|
270
|
+
// INTENTIONALLY `result.exits.length` (a number) — a deliberate
|
|
271
|
+
// divergence from the `/v1/webhooks/:sourceId` route, which returns the
|
|
272
|
+
// ExitResult[] ARRAY for back-compat. Do NOT unify the two.
|
|
273
|
+
return c.json(
|
|
274
|
+
{ ok: true, event: event.event, exits: result.exits.length },
|
|
275
|
+
200,
|
|
276
|
+
);
|
|
277
|
+
},
|
|
278
|
+
);
|
|
279
|
+
}
|
package/src/routes/index.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
2
|
import type { AppEnv } from "../app.js";
|
|
3
|
+
import type { HogsendClient } from "../container.js";
|
|
3
4
|
import { requireApiKey, requireScope } from "../middleware/api-key.js";
|
|
4
5
|
import { createRateLimit } from "../middleware/rate-limit.js";
|
|
5
|
-
import type { DefinedWebhookSource } from "../webhook-sources/define-webhook-source.js";
|
|
6
6
|
import { adminRouter } from "./admin/index.js";
|
|
7
7
|
import { campaignsRouter } from "./campaigns/index.js";
|
|
8
|
+
import { registerConnectorRoutes } from "./connectors/index.js";
|
|
8
9
|
import { contactsRouter } from "./contacts/index.js";
|
|
9
10
|
import { emailRouter } from "./email/index.js";
|
|
10
11
|
import { emailsRouter } from "./emails/index.js";
|
|
@@ -15,7 +16,7 @@ import { trackingRouter } from "./tracking/index.js";
|
|
|
15
16
|
import { registerWebhookRoutes } from "./webhooks/index.js";
|
|
16
17
|
|
|
17
18
|
export interface RegisterRoutesOptions {
|
|
18
|
-
|
|
19
|
+
container: HogsendClient;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
// Conservative per-key email budget. `/v1/emails` MUST use a distinct prefix so
|
|
@@ -76,7 +77,19 @@ export function registerRoutes(
|
|
|
76
77
|
|
|
77
78
|
app.route("/v1", v1);
|
|
78
79
|
|
|
80
|
+
// Generic connector dispatch (oauth/interactions/ingress) — the static
|
|
81
|
+
// `connectors/` prefix is registered BEFORE the `:sourceId` webhook catch-all
|
|
82
|
+
// so it wins path matching. These routes self-authenticate (oauth state +
|
|
83
|
+
// code, ed25519 signatures, the shared ingress secret) and are intentionally
|
|
84
|
+
// OUTSIDE the api-key data plane — see registerConnectorRoutes.
|
|
85
|
+
registerConnectorRoutes(app);
|
|
86
|
+
|
|
79
87
|
// Webhooks (built-in Resend + injected content sources) are registered on the
|
|
80
|
-
// app at absolute paths.
|
|
81
|
-
|
|
88
|
+
// app at absolute paths. The webhook route sources its connectors from the
|
|
89
|
+
// container's unified registry (transport === "webhook"), NOT from a passed
|
|
90
|
+
// array.
|
|
91
|
+
registerWebhookRoutes(app, {
|
|
92
|
+
webhookConnectors:
|
|
93
|
+
opts.container.connectorRegistry.getByTransport("webhook"),
|
|
94
|
+
});
|
|
82
95
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
2
|
import type { AppEnv } from "../../app.js";
|
|
3
|
-
import type {
|
|
3
|
+
import type { DefinedConnector } from "../../connectors/define-connector.js";
|
|
4
4
|
import { registerEmailProviderRoutes } from "./email-provider.js";
|
|
5
5
|
import { resendWebhookRouter } from "./resend.js";
|
|
6
6
|
import { registerWebhookSourceRoutes } from "./sources.js";
|
|
7
7
|
|
|
8
8
|
export interface RegisterWebhookRoutesOptions {
|
|
9
|
-
|
|
9
|
+
webhookConnectors: DefinedConnector[]; // pre-filtered to transport "webhook"
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export function registerWebhookRoutes(
|
|
@@ -20,5 +20,5 @@ export function registerWebhookRoutes(
|
|
|
20
20
|
// 3. the `/v1/webhooks/:sourceId` consumer-source catch-all (LAST).
|
|
21
21
|
app.route("/v1/webhooks", resendWebhookRouter);
|
|
22
22
|
registerEmailProviderRoutes(app);
|
|
23
|
-
registerWebhookSourceRoutes(app, opts.
|
|
23
|
+
registerWebhookSourceRoutes(app, opts.webhookConnectors);
|
|
24
24
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { Database } from "@hogsend/db";
|
|
2
2
|
import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
3
|
import type { AppEnv } from "../../app.js";
|
|
4
|
+
import type { DefinedConnector } from "../../connectors/define-connector.js";
|
|
4
5
|
import { headersToRecord } from "../../lib/headers.js";
|
|
5
6
|
import { ingestEvent } from "../../lib/ingestion.js";
|
|
6
7
|
import type { Logger } from "../../lib/logger.js";
|
|
7
8
|
import { getDerivedCredential } from "../../lib/provider-credentials.js";
|
|
8
|
-
import type { DefinedWebhookSource } from "../../webhook-sources/define-webhook-source.js";
|
|
9
9
|
import { verifySignature } from "../../webhook-sources/verify.js";
|
|
10
10
|
|
|
11
11
|
/** Negative-cache window for the stored PostHog secret (mirrors the token
|
|
@@ -64,7 +64,7 @@ export function invalidateStoredPosthogSecret(): void {
|
|
|
64
64
|
|
|
65
65
|
export function registerWebhookSourceRoutes(
|
|
66
66
|
app: OpenAPIHono<AppEnv>,
|
|
67
|
-
sources:
|
|
67
|
+
sources: DefinedConnector[], // already filtered to transport === "webhook"
|
|
68
68
|
) {
|
|
69
69
|
// Reserve `email` for the email-provider route
|
|
70
70
|
// (`POST /v1/webhooks/email/:providerId`). A source with `meta.id === "email"`
|
|
@@ -122,6 +122,12 @@ export function registerWebhookSourceRoutes(
|
|
|
122
122
|
return c.json({ error: "Unknown webhook source" }, 404);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
// Webhook-transport connectors always carry inboundVerify (defineConnector
|
|
126
|
+
// enforces it at authoring time). Narrow once so the rest of the auth ladder
|
|
127
|
+
// is byte-identical to the pre-connector source dispatch.
|
|
128
|
+
const auth = source.inboundVerify;
|
|
129
|
+
if (!auth) return c.json({ error: "Unknown webhook source" }, 404);
|
|
130
|
+
|
|
125
131
|
const { db, logger, env, registry, hatchet } = c.get("container");
|
|
126
132
|
|
|
127
133
|
// Read the body ONCE as the EXACT received bytes — signature schemes verify
|
|
@@ -129,9 +135,7 @@ export function registerWebhookSourceRoutes(
|
|
|
129
135
|
const rawBody = await c.req.text();
|
|
130
136
|
const headers = headersToRecord(c.req.raw.headers);
|
|
131
137
|
|
|
132
|
-
let secret = env[
|
|
133
|
-
| string
|
|
134
|
-
| undefined;
|
|
138
|
+
let secret = env[auth.envKey as keyof typeof env] as string | undefined;
|
|
135
139
|
|
|
136
140
|
// For the inbound PostHog source, fall back to the secret minted by
|
|
137
141
|
// `hogsend connect` (kind="derived" store) when env has none — so an
|
|
@@ -139,13 +143,13 @@ export function registerWebhookSourceRoutes(
|
|
|
139
143
|
// neither env nor the store has a secret (current behavior preserved).
|
|
140
144
|
if (
|
|
141
145
|
!secret &&
|
|
142
|
-
|
|
143
|
-
|
|
146
|
+
auth.type === "match" &&
|
|
147
|
+
auth.envKey === "POSTHOG_WEBHOOK_SECRET"
|
|
144
148
|
) {
|
|
145
149
|
secret = await resolveStoredPosthogSecret(db, logger);
|
|
146
150
|
}
|
|
147
151
|
|
|
148
|
-
if (
|
|
152
|
+
if (auth.type === "signature") {
|
|
149
153
|
// Signature sources FAIL CLOSED: an unset secret is a 401, never an open
|
|
150
154
|
// pass-through (deliberate divergence from the "match" variant).
|
|
151
155
|
if (!secret) {
|
|
@@ -155,7 +159,6 @@ export function registerWebhookSourceRoutes(
|
|
|
155
159
|
return c.json({ error: "Webhook signature not configured" }, 401);
|
|
156
160
|
}
|
|
157
161
|
|
|
158
|
-
const auth = source.auth;
|
|
159
162
|
let verified = false;
|
|
160
163
|
|
|
161
164
|
if (auth.verify) {
|
|
@@ -183,7 +186,7 @@ export function registerWebhookSourceRoutes(
|
|
|
183
186
|
// (parity with the pre-engine route).
|
|
184
187
|
if (secret) {
|
|
185
188
|
const provided =
|
|
186
|
-
headers[
|
|
189
|
+
headers[auth.header.toLowerCase()] ??
|
|
187
190
|
headers.authorization?.replace("Bearer ", "");
|
|
188
191
|
|
|
189
192
|
if (provided !== secret) {
|
|
@@ -216,6 +219,7 @@ export function registerWebhookSourceRoutes(
|
|
|
216
219
|
const event = await source.transform(payload, {
|
|
217
220
|
db,
|
|
218
221
|
logger,
|
|
222
|
+
transport: "webhook",
|
|
219
223
|
rawBody,
|
|
220
224
|
headers,
|
|
221
225
|
});
|
|
@@ -230,6 +234,12 @@ export function registerWebhookSourceRoutes(
|
|
|
230
234
|
ok: true,
|
|
231
235
|
event: event.event,
|
|
232
236
|
userId: event.userId,
|
|
237
|
+
// INTENTIONALLY the ExitResult[] ARRAY (not `.length`) — preserved
|
|
238
|
+
// byte-for-byte for back-compat. The OpenAPI schema declares
|
|
239
|
+
// `exits: z.number().optional()`, but this route has always returned the
|
|
240
|
+
// array; the NEW `/v1/connectors/:id/ingress` route returns
|
|
241
|
+
// `result.exits.length` as a deliberate divergence. Do NOT "tidy" either
|
|
242
|
+
// to match the other.
|
|
233
243
|
exits: result.exits,
|
|
234
244
|
});
|
|
235
245
|
});
|
|
@@ -1,52 +1,36 @@
|
|
|
1
1
|
import type { Database } from "@hogsend/db";
|
|
2
2
|
import type { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
type ConnectorCtx,
|
|
5
|
+
type DefinedConnector,
|
|
6
|
+
defineConnector,
|
|
7
|
+
type InboundVerifyAuth,
|
|
8
|
+
} from "../connectors/define-connector.js";
|
|
3
9
|
import type { IngestEvent } from "../lib/ingestion.js";
|
|
4
10
|
import type { Logger } from "../lib/logger.js";
|
|
5
|
-
import type { SignatureScheme, VerifySignatureArgs } from "./verify.js";
|
|
6
11
|
|
|
7
12
|
/**
|
|
8
|
-
*
|
|
13
|
+
* @deprecated naming only — `defineWebhookSource` is now the
|
|
14
|
+
* `transport: "webhook"` specialization of {@link defineConnector}, kept as a
|
|
15
|
+
* behavior- and signature-identical alias. NO migration is required.
|
|
9
16
|
*
|
|
10
|
-
*
|
|
17
|
+
* `WebhookSourceAuth` is an alias of the connector's inbound-verify union —
|
|
18
|
+
* IDENTICAL shape today, so every existing source's `auth` keeps type-checking.
|
|
11
19
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* generic hex HMAC). The route resolves the secret from `env[envKey]`, reads
|
|
19
|
-
* the EXACT raw request body, and calls `verifySignature` (or the optional
|
|
20
|
-
* per-source `verify` override) over those bytes. Signature sources FAIL
|
|
21
|
-
* CLOSED (401) when their secret is unset — they are security-sensitive.
|
|
20
|
+
* SURFACE PIN: this alias must stay byte-for-byte equal to the frozen
|
|
21
|
+
* `{ match | signature }` webhook auth shape. `__tests__/connectors.test.ts`
|
|
22
|
+
* has a type-level assertion (`expectTypeOf<WebhookSourceAuth>()...`) that fails
|
|
23
|
+
* the build if `InboundVerifyAuth` ever gains a third variant — so a future
|
|
24
|
+
* additive change to the connector union can never silently widen this frozen
|
|
25
|
+
* public webhook-source surface.
|
|
22
26
|
*/
|
|
23
|
-
export type WebhookSourceAuth =
|
|
24
|
-
| {
|
|
25
|
-
type: "match";
|
|
26
|
-
header: string;
|
|
27
|
-
envKey: string;
|
|
28
|
-
}
|
|
29
|
-
| {
|
|
30
|
-
type: "signature";
|
|
31
|
-
scheme: SignatureScheme;
|
|
32
|
-
envKey: string;
|
|
33
|
-
header: string;
|
|
34
|
-
/**
|
|
35
|
-
* For schemes (notably `"svix"`) whose providers may also send a plain
|
|
36
|
-
* shared-secret header: when the scheme's signature headers are absent but
|
|
37
|
-
* this header matches the secret verbatim, accept the request. Lets
|
|
38
|
-
* Supabase's `x-supabase-webhook-secret` plain-secret mode coexist with
|
|
39
|
-
* its Svix mode.
|
|
40
|
-
*/
|
|
41
|
-
fallbackMatchHeader?: string;
|
|
42
|
-
/**
|
|
43
|
-
* Optional per-source override of the built-in scheme verification. When
|
|
44
|
-
* provided, the route calls this INSTEAD of `verifySignature(scheme, …)`.
|
|
45
|
-
* Receives the EXACT received bytes; must return (or resolve to) a boolean.
|
|
46
|
-
*/
|
|
47
|
-
verify?(args: VerifySignatureArgs): boolean | Promise<boolean>;
|
|
48
|
-
};
|
|
27
|
+
export type WebhookSourceAuth = InboundVerifyAuth;
|
|
49
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Unchanged public shape. `ctx` stays the narrow webhook-only context —
|
|
31
|
+
* `ConnectorCtx` minus `transport` — so consumer transforms typed against this
|
|
32
|
+
* are byte-for-byte source-compatible.
|
|
33
|
+
*/
|
|
50
34
|
export interface WebhookSourceCtx {
|
|
51
35
|
db: Database;
|
|
52
36
|
logger: Logger;
|
|
@@ -73,9 +57,30 @@ export interface DefinedWebhookSource<T = unknown> {
|
|
|
73
57
|
transform(payload: T, ctx: WebhookSourceCtx): Promise<IngestEvent | null>;
|
|
74
58
|
}
|
|
75
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Lift a `DefinedWebhookSource` onto the connector umbrella as a
|
|
62
|
+
* `transport: "webhook"` connector: `auth` → `inboundVerify`, transform `ctx`
|
|
63
|
+
* widened to {@link ConnectorCtx} (the webhook route always sets
|
|
64
|
+
* `transport: "webhook"` + `rawBody`/`headers`). Used by the container to
|
|
65
|
+
* register webhook sources into the unified {@link ConnectorRegistry}.
|
|
66
|
+
*/
|
|
67
|
+
export function webhookSourceToConnector<T>(
|
|
68
|
+
source: DefinedWebhookSource<T>,
|
|
69
|
+
): DefinedConnector<T> {
|
|
70
|
+
return defineConnector<T>({
|
|
71
|
+
meta: { ...source.meta, transport: "webhook" },
|
|
72
|
+
inboundVerify: source.auth,
|
|
73
|
+
schema: source.schema,
|
|
74
|
+
transform: (payload: T, ctx: ConnectorCtx) =>
|
|
75
|
+
source.transform(payload, ctx),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
76
79
|
export function defineWebhookSource<T>(
|
|
77
80
|
def: DefinedWebhookSource<T>,
|
|
78
81
|
): DefinedWebhookSource<T> {
|
|
82
|
+
// Unchanged contract: returns its argument. The container converts it via
|
|
83
|
+
// webhookSourceToConnector when building the registry.
|
|
79
84
|
return def;
|
|
80
85
|
}
|
|
81
86
|
|
|
@@ -45,8 +45,11 @@ function lowerHeaders(headers: Record<string, string>): Record<string, string> {
|
|
|
45
45
|
/**
|
|
46
46
|
* Constant-time string comparison that never short-circuits on length. Returns
|
|
47
47
|
* `false` (rather than throwing) on a length mismatch so callers fail closed.
|
|
48
|
+
* Exported so the connector ingress route reuses ONE hardened compare rather
|
|
49
|
+
* than re-implementing `Buffer.from` + `timingSafeEqual` inline (where a later
|
|
50
|
+
* refactor could drop the length guard and reintroduce the throw-on-mismatch).
|
|
48
51
|
*/
|
|
49
|
-
function safeEqual(a: string, b: string): boolean {
|
|
52
|
+
export function safeEqual(a: string, b: string): boolean {
|
|
50
53
|
const bufA = Buffer.from(a, "utf8");
|
|
51
54
|
const bufB = Buffer.from(b, "utf8");
|
|
52
55
|
if (bufA.length !== bufB.length) {
|