@hogsend/engine 0.21.0 → 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.
@@ -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
+ }
@@ -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
- webhookSources: DefinedWebhookSource[];
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
- registerWebhookRoutes(app, { webhookSources: opts.webhookSources });
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 { DefinedWebhookSource } from "../../webhook-sources/define-webhook-source.js";
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
- webhookSources: DefinedWebhookSource[];
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.webhookSources);
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
@@ -52,9 +52,19 @@ async function resolveStoredPosthogSecret(
52
52
  return value;
53
53
  }
54
54
 
55
+ /**
56
+ * Drop the module-level stored-secret cache so the next inbound PostHog webhook
57
+ * re-reads from the `kind="derived"` store. Called right after `hogsend connect`
58
+ * mints + persists a secret, so the freshly-minted value is enforced
59
+ * immediately instead of waiting out the `STORED_SECRET_RECHECK_MS` window.
60
+ */
61
+ export function invalidateStoredPosthogSecret(): void {
62
+ storedPosthogSecret = undefined;
63
+ }
64
+
55
65
  export function registerWebhookSourceRoutes(
56
66
  app: OpenAPIHono<AppEnv>,
57
- sources: DefinedWebhookSource[],
67
+ sources: DefinedConnector[], // already filtered to transport === "webhook"
58
68
  ) {
59
69
  // Reserve `email` for the email-provider route
60
70
  // (`POST /v1/webhooks/email/:providerId`). A source with `meta.id === "email"`
@@ -112,6 +122,12 @@ export function registerWebhookSourceRoutes(
112
122
  return c.json({ error: "Unknown webhook source" }, 404);
113
123
  }
114
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
+
115
131
  const { db, logger, env, registry, hatchet } = c.get("container");
116
132
 
117
133
  // Read the body ONCE as the EXACT received bytes — signature schemes verify
@@ -119,9 +135,7 @@ export function registerWebhookSourceRoutes(
119
135
  const rawBody = await c.req.text();
120
136
  const headers = headersToRecord(c.req.raw.headers);
121
137
 
122
- let secret = env[source.auth.envKey as keyof typeof env] as
123
- | string
124
- | undefined;
138
+ let secret = env[auth.envKey as keyof typeof env] as string | undefined;
125
139
 
126
140
  // For the inbound PostHog source, fall back to the secret minted by
127
141
  // `hogsend connect` (kind="derived" store) when env has none — so an
@@ -129,13 +143,13 @@ export function registerWebhookSourceRoutes(
129
143
  // neither env nor the store has a secret (current behavior preserved).
130
144
  if (
131
145
  !secret &&
132
- source.auth.type === "match" &&
133
- source.auth.envKey === "POSTHOG_WEBHOOK_SECRET"
146
+ auth.type === "match" &&
147
+ auth.envKey === "POSTHOG_WEBHOOK_SECRET"
134
148
  ) {
135
149
  secret = await resolveStoredPosthogSecret(db, logger);
136
150
  }
137
151
 
138
- if (source.auth.type === "signature") {
152
+ if (auth.type === "signature") {
139
153
  // Signature sources FAIL CLOSED: an unset secret is a 401, never an open
140
154
  // pass-through (deliberate divergence from the "match" variant).
141
155
  if (!secret) {
@@ -145,7 +159,6 @@ export function registerWebhookSourceRoutes(
145
159
  return c.json({ error: "Webhook signature not configured" }, 401);
146
160
  }
147
161
 
148
- const auth = source.auth;
149
162
  let verified = false;
150
163
 
151
164
  if (auth.verify) {
@@ -173,7 +186,7 @@ export function registerWebhookSourceRoutes(
173
186
  // (parity with the pre-engine route).
174
187
  if (secret) {
175
188
  const provided =
176
- headers[source.auth.header.toLowerCase()] ??
189
+ headers[auth.header.toLowerCase()] ??
177
190
  headers.authorization?.replace("Bearer ", "");
178
191
 
179
192
  if (provided !== secret) {
@@ -206,6 +219,7 @@ export function registerWebhookSourceRoutes(
206
219
  const event = await source.transform(payload, {
207
220
  db,
208
221
  logger,
222
+ transport: "webhook",
209
223
  rawBody,
210
224
  headers,
211
225
  });
@@ -220,6 +234,12 @@ export function registerWebhookSourceRoutes(
220
234
  ok: true,
221
235
  event: event.event,
222
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.
223
243
  exits: result.exits,
224
244
  });
225
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
- * How a webhook source authenticates inbound requests.
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
- * A discriminated union on `type`:
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
- * - `"match"` plain shared-secret equality. The route compares a configured
13
- * secret against the request header (or `Authorization: Bearer`). When the
14
- * secret is UNSET the source stays OPEN (parity with the pre-engine route);
15
- * this variant is unchanged so PostHog + all consumer sources keep compiling.
16
- *
17
- * - `"signature"` provider HMAC signature verification (Svix / Stripe /
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) {