@hogsend/engine 0.21.1 → 0.23.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.
@@ -38,6 +38,8 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
38
38
  id: trackedLinks.id,
39
39
  originalUrl: trackedLinks.originalUrl,
40
40
  emailSendId: trackedLinks.emailSendId,
41
+ distinctId: trackedLinks.distinctId,
42
+ source: trackedLinks.source,
41
43
  event: trackedLinks.event,
42
44
  eventProperties: trackedLinks.eventProperties,
43
45
  })
@@ -56,10 +58,17 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
56
58
  null;
57
59
  const userAgent = c.req.header("user-agent") ?? null;
58
60
 
61
+ // The linkClicks insert + clickCount increment stay UNCONDITIONAL — every
62
+ // tracked link counts clicks, email or not.
63
+ //
59
64
  // First-touch state UPDATE: the `WHERE clickedAt IS NULL` sets `clickedAt`
60
65
  // exactly once (the first click), which is the row-level state we keep. The
61
66
  // outbound emit is NO LONGER gated on this — every destination must receive
62
- // EVERY click (owner decision 1), so the emit below fires per-hit.
67
+ // EVERY click (owner decision 1), so the emit below fires per-hit. The
68
+ // emailSends update is GATED on `emailSendId != null` (MF-6): a non-email
69
+ // link has no send row to mark. This was previously safe-by-accident
70
+ // (`WHERE id = NULL` matches nothing) — the gate makes it explicit.
71
+ const emailSendId = link.emailSendId;
63
72
  await Promise.all([
64
73
  db.insert(linkClicks).values({
65
74
  trackedLinkId: link.id,
@@ -73,18 +82,22 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
73
82
  updatedAt: new Date(),
74
83
  })
75
84
  .where(eq(trackedLinks.id, link.id)),
76
- db
77
- .update(emailSends)
78
- .set({
79
- clickedAt: new Date(),
80
- updatedAt: new Date(),
81
- })
82
- .where(
83
- and(
84
- eq(emailSends.id, link.emailSendId),
85
- isNull(emailSends.clickedAt),
86
- ),
87
- ),
85
+ ...(emailSendId
86
+ ? [
87
+ db
88
+ .update(emailSends)
89
+ .set({
90
+ clickedAt: new Date(),
91
+ updatedAt: new Date(),
92
+ })
93
+ .where(
94
+ and(
95
+ eq(emailSends.id, emailSendId),
96
+ isNull(emailSends.clickedAt),
97
+ ),
98
+ ),
99
+ ]
100
+ : []),
88
101
  ]);
89
102
 
90
103
  const { hatchet, registry, logger } = c.get("container");
@@ -93,8 +106,11 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
93
106
  // deferred past the scanner-burst window (a Hatchet task) so the gate can
94
107
  // see the WHOLE burst — an inline check could never suppress a scanner's
95
108
  // first click. The task claims the send's answer slot (first answer wins)
96
- // and emits the consumer event + email.action outbound.
97
- if (link.event) {
109
+ // and emits the consumer event + email.action outbound. GATED on
110
+ // `emailSendId != null` (MF-6): the confirm task is email-semantic (it
111
+ // claims a send's answer slot + emits `email.action`), so a non-email
112
+ // semantic link would have no send to confirm against.
113
+ if (link.event && emailSendId) {
98
114
  void confirmSemanticClickTask
99
115
  .runNoWait({
100
116
  trackedLinkId: link.id,
@@ -110,27 +126,48 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
110
126
  }
111
127
 
112
128
  // Cross-device identity stitch (opt-in): append a short-lived signed
113
- // `hs_t` token to the destination so the landing site can identify the
114
- // session. This is the ONE path that needs the send context BEFORE the
115
- // redirect the awaited resolve is shared with the async chain below so
116
- // the read still happens once.
129
+ // `hs_t` token to the destination so the landing site can fold its own anon
130
+ // session INTO the subject at `/v1/t/identify`. Two mint sources by link
131
+ // type (§6.5): a stitch-bearing NON-email link mints from its own
132
+ // `distinct_id` (`src: "<source>:<id>"`); an EMAIL link mints from the
133
+ // resolved send context; a BROADCAST link (no `distinct_id`, no send) mints
134
+ // nothing. The token is minted at CLICK time only — never stored on the
135
+ // shareable `/v1/t/c/:id` artifact (§6.3). The awaited send resolve is
136
+ // shared with the async chain below so the read still happens once.
117
137
  let redirectUrl = link.originalUrl;
118
138
  let preResolved: Awaited<
119
139
  ReturnType<typeof resolveEmailSendContext>
120
140
  > | null = null;
121
141
  let preResolvedSet = false;
122
142
  if (env.TRACKING_IDENTITY_TOKEN) {
123
- preResolved = await resolveEmailSendContext(db, link.emailSendId);
124
- preResolvedSet = true;
125
- if (preResolved?.userId) {
143
+ let tokenDistinctId: string | null = null;
144
+ let tokenSrc: string | null = null;
145
+ if (link.distinctId) {
146
+ // Stitch-bearing non-email link: the subject is the link's own
147
+ // `distinct_id` (canonical key). No send resolve needed.
148
+ tokenDistinctId = link.distinctId;
149
+ tokenSrc = `${link.source ?? "link"}:${link.id}`;
150
+ } else if (emailSendId) {
151
+ // Email link: resolve the recipient's canonical key from the send row.
152
+ preResolved = await resolveEmailSendContext(db, emailSendId);
153
+ preResolvedSet = true;
154
+ if (preResolved?.userId) {
155
+ tokenDistinctId = preResolved.userId;
156
+ tokenSrc = `email:${emailSendId}`;
157
+ }
158
+ }
159
+ // else: broadcast link (no distinctId, no send) — mint nothing.
160
+
161
+ if (tokenDistinctId && tokenSrc) {
126
162
  try {
127
163
  const url = new URL(link.originalUrl);
128
164
  url.searchParams.set(
129
165
  "hs_t",
130
166
  generateIdentityToken({
131
167
  secret: env.BETTER_AUTH_SECRET,
132
- distinctId: preResolved.userId,
133
- emailSendId: link.emailSendId,
168
+ distinctId: tokenDistinctId,
169
+ src: tokenSrc,
170
+ emailSendId: emailSendId ?? undefined,
134
171
  }),
135
172
  );
136
173
  redirectUrl = url.toString();
@@ -141,58 +178,80 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
141
178
  }
142
179
  }
143
180
 
144
- // Resolve the send context ONCE (off the response path) and feed both the
145
- // re-ingest and the PER-HIT outbound emit avoiding a duplicate
146
- // `resolveEmailSendContext` read on the click hot path. NO `dedupeKey`: a
147
- // NULL dedupe key is distinct in Postgres, so every click creates a fresh
148
- // delivery to every subscribed destination (per-hit, not first-touch).
149
- const emailSendId = link.emailSendId;
150
- void (
151
- preResolvedSet
152
- ? Promise.resolve(preResolved)
153
- : resolveEmailSendContext(db, emailSendId)
154
- )
155
- .then(async (ctx) => {
156
- await pushTrackingEvent({
157
- db,
158
- hatchet,
159
- registry,
160
- logger,
161
- event: EMAIL_LINK_CLICKED,
162
- emailSendId,
163
- properties: { linkUrl: link.originalUrl, linkId: link.id },
164
- resolvedContext: ctx,
165
- }).catch((err) => {
166
- logger.warn("Failed to push click tracking event", {
167
- linkId: link.id,
168
- error: err instanceof Error ? err.message : String(err),
169
- });
170
- });
171
-
172
- // Only emit when the send-context resolved. A missing emailSends row
173
- // (orphaned tracked link / deleted send) has no userId or recipient to
174
- // attribute, and a keyed destination (PostHog) would otherwise receive
175
- // an empty distinct_id. A normal click always resolves a non-null userId.
176
- if (ctx) {
177
- await emitOutbound({
181
+ // PER-HIT outbound emit, off the response path. EMAIL links re-ingest the
182
+ // first-party `email.link_clicked` event (journey routing + userEvents) and
183
+ // emit `email.clicked`; NON-email links emit `link.clicked` instead never
184
+ // a malformed `email.clicked` (MF-missing #3). NO `dedupeKey`: a NULL dedupe
185
+ // key is distinct in Postgres, so every click creates a fresh delivery to
186
+ // every subscribed destination (per-hit, not first-touch).
187
+ if (emailSendId) {
188
+ void (
189
+ preResolvedSet
190
+ ? Promise.resolve(preResolved)
191
+ : resolveEmailSendContext(db, emailSendId)
192
+ )
193
+ .then(async (ctx) => {
194
+ await pushTrackingEvent({
178
195
  db,
179
196
  hatchet,
197
+ registry,
180
198
  logger,
181
- event: "email.clicked",
182
- payload: {
183
- emailSendId,
184
- messageId: ctx.messageId ?? null,
185
- templateKey: ctx.templateKey ?? null,
186
- userId: ctx.userId ?? null,
187
- to: ctx.to ?? ctx.userEmail ?? "",
188
- at: new Date().toISOString(),
189
- linkUrl: link.originalUrl,
199
+ event: EMAIL_LINK_CLICKED,
200
+ emailSendId,
201
+ properties: { linkUrl: link.originalUrl, linkId: link.id },
202
+ resolvedContext: ctx,
203
+ }).catch((err) => {
204
+ logger.warn("Failed to push click tracking event", {
190
205
  linkId: link.id,
191
- },
206
+ error: err instanceof Error ? err.message : String(err),
207
+ });
192
208
  });
193
- }
194
- })
195
- .catch(logger.warn);
209
+
210
+ // Only emit when the send-context resolved. A missing emailSends row
211
+ // (orphaned tracked link / deleted send) has no userId or recipient to
212
+ // attribute, and a keyed destination (PostHog) would otherwise receive
213
+ // an empty distinct_id. A normal click always resolves a non-null userId.
214
+ if (ctx) {
215
+ await emitOutbound({
216
+ db,
217
+ hatchet,
218
+ logger,
219
+ event: "email.clicked",
220
+ payload: {
221
+ emailSendId,
222
+ messageId: ctx.messageId ?? null,
223
+ templateKey: ctx.templateKey ?? null,
224
+ userId: ctx.userId ?? null,
225
+ to: ctx.to ?? ctx.userEmail ?? "",
226
+ at: new Date().toISOString(),
227
+ linkUrl: link.originalUrl,
228
+ linkId: link.id,
229
+ },
230
+ });
231
+ }
232
+ })
233
+ .catch(logger.warn);
234
+ } else {
235
+ // Non-email tracked link: emit the catalogued `link.clicked` (NOT
236
+ // `email.clicked`). `userId` is the link's stitch subject when
237
+ // identity-bearing, else null for a broadcast link. No re-ingest — the
238
+ // first-party `email.link_clicked` bus event is email-semantic.
239
+ void emitOutbound({
240
+ db,
241
+ hatchet,
242
+ logger,
243
+ event: "link.clicked",
244
+ payload: {
245
+ linkId: link.id,
246
+ source: link.source ?? null,
247
+ userId: link.distinctId ?? null,
248
+ emailSendId: null,
249
+ messageId: null,
250
+ linkUrl: link.originalUrl,
251
+ at: new Date().toISOString(),
252
+ },
253
+ }).catch(logger.warn);
254
+ }
196
255
 
197
256
  return c.redirect(redirectUrl, 302);
198
257
  },
@@ -6,26 +6,40 @@ import {
6
6
  } from "../../lib/identity-token.js";
7
7
 
8
8
  /**
9
- * Exchange a redirect identity token (`hs_t`) for the distinct id. Called by
10
- * the LANDING SITE's frontend (CORS is open app-wide) after the user arrives
11
- * from a tracked email link; the site then calls `posthog.identify` (or its
12
- * analytics equivalent) with the result ideally gated behind whatever
13
- * analytics consent the site already operates under.
9
+ * Exchange a redirect identity token (`hs_t`) for the distinct id, AND — when
10
+ * the caller supplies its own browser anon id (`currentDistinctId`) and the
11
+ * active analytics provider can merge fire a SERVER-SIDE `alias` folding the
12
+ * caller's own anon session INTO the token's canonical subject. Called by the
13
+ * LANDING SITE's frontend (CORS is open app-wide) after the user arrives from a
14
+ * tracked link.
14
15
  *
15
16
  * Possession of a fresh signed token IS the authorization (the same trust
16
17
  * model as unsubscribe links): tokens are signed with BETTER_AUTH_SECRET,
17
- * expire after an hour, and resolve to nothing but the distinct id + send id.
18
+ * expire after an hour, and resolve to nothing but the canonical key + src.
19
+ *
20
+ * ANTI-HIJACK (MF-4): the route NEVER passes `currentDistinctId` as the
21
+ * survivor and NEVER server-identifies it. A forwarded-token holder can, at
22
+ * worst, fold THEIR OWN anon session into the subject — never overwrite the
23
+ * subject, never become the subject, never name a victim's anon id (they don't
24
+ * know it). A scanner following the redirect runs no posthog-js, so it supplies
25
+ * no `currentDistinctId` and the merge no-ops — the exchange is inert for
26
+ * headless prefetch.
18
27
  */
19
28
  const identifyRoute = createRoute({
20
29
  method: "post",
21
30
  path: "/identify",
22
31
  tags: ["Tracking"],
23
- summary: "Exchange a redirect identity token for the distinct id",
32
+ summary: "Exchange a redirect identity token + optionally alias the caller",
24
33
  request: {
25
34
  body: {
26
35
  content: {
27
36
  "application/json": {
28
- schema: z.object({ token: z.string().min(1).max(2048) }),
37
+ schema: z.object({
38
+ token: z.string().min(1).max(2048),
39
+ // The caller's OWN browser anon distinct id, to be folded INTO the
40
+ // token subject. Optional — absent = legacy resolve-only behaviour.
41
+ currentDistinctId: z.string().min(1).max(200).optional(),
42
+ }),
29
43
  },
30
44
  },
31
45
  },
@@ -35,8 +49,11 @@ const identifyRoute = createRoute({
35
49
  description: "Resolved identity",
36
50
  content: {
37
51
  "application/json": {
52
+ // ONE response schema across §6 (MF-5): `src` is the new field,
53
+ // `emailSendId` retained for the one-minor deprecation window.
38
54
  schema: z.object({
39
55
  distinctId: z.string(),
56
+ src: z.string(),
40
57
  emailSendId: z.string().optional(),
41
58
  }),
42
59
  },
@@ -49,23 +66,53 @@ const identifyRoute = createRoute({
49
66
  export const identifyRouter = new OpenAPIHono<AppEnv>().openapi(
50
67
  identifyRoute,
51
68
  async (c) => {
52
- const { token } = c.req.valid("json");
53
- const { env } = c.get("container");
69
+ const { token, currentDistinctId } = c.req.valid("json");
70
+ const { env, analytics, logger } = c.get("container");
54
71
 
72
+ let payload: ReturnType<typeof validateIdentityToken>;
55
73
  try {
56
- const payload = validateIdentityToken({
74
+ payload = validateIdentityToken({
57
75
  token,
58
76
  secret: env.BETTER_AUTH_SECRET,
59
77
  });
60
- return c.json(
61
- { distinctId: payload.distinctId, emailSendId: payload.emailSendId },
62
- 200,
63
- );
64
78
  } catch (err) {
65
79
  if (err instanceof InvalidIdentityTokenError) {
66
80
  return c.body(null, 400);
67
81
  }
68
82
  throw err;
69
83
  }
84
+
85
+ // MF-5 — fire the alias FIRE-AND-FORGET (never await on the response path)
86
+ // and respond synchronously. The token-proven canonical key is the survivor;
87
+ // the caller's own session is the absorbed (anonymous) side. A provider
88
+ // without `identityMerge` (or no provider) skips the merge cleanly — the
89
+ // client falls back to its existing best-effort `posthog.identify`.
90
+ if (
91
+ currentDistinctId &&
92
+ analytics?.capabilities.identityMerge &&
93
+ analytics.mergeIdentities &&
94
+ currentDistinctId !== payload.distinctId
95
+ ) {
96
+ try {
97
+ analytics.mergeIdentities({
98
+ distinctId: payload.distinctId,
99
+ alias: currentDistinctId,
100
+ });
101
+ } catch (err) {
102
+ // Best-effort — a provider error must never fail the exchange.
103
+ logger.warn("identify: mergeIdentities failed", {
104
+ error: err instanceof Error ? err.message : String(err),
105
+ });
106
+ }
107
+ }
108
+
109
+ return c.json(
110
+ {
111
+ distinctId: payload.distinctId,
112
+ src: payload.src,
113
+ emailSendId: payload.emailSendId,
114
+ },
115
+ 200,
116
+ );
70
117
  },
71
118
  );
@@ -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
@@ -64,7 +64,7 @@ export function invalidateStoredPosthogSecret(): void {
64
64
 
65
65
  export function registerWebhookSourceRoutes(
66
66
  app: OpenAPIHono<AppEnv>,
67
- sources: DefinedWebhookSource[],
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[source.auth.envKey as keyof typeof env] as
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
- source.auth.type === "match" &&
143
- source.auth.envKey === "POSTHOG_WEBHOOK_SECRET"
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 (source.auth.type === "signature") {
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[source.auth.header.toLowerCase()] ??
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
- * 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) {