@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.
@@ -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
+ }
@@ -39,6 +39,10 @@ const upsertRoute = createRoute({
39
39
  schema: z.object({
40
40
  email: z.string().email().optional(),
41
41
  userId: z.string().min(1).optional(),
42
+ // §4: caller's analytics anon id — the resolver's 2nd-precedence
43
+ // key. An EXTRA, never a third identity arm: `requireIdentity`
44
+ // still requires email or userId below.
45
+ anonymousId: z.string().min(1).max(200).optional(),
42
46
  properties: z.record(z.string(), z.unknown()).optional(),
43
47
  lists: z.record(z.string(), z.boolean()).optional(),
44
48
  }),
@@ -142,6 +146,9 @@ export const contactsRouter = new OpenAPIHono<AppEnv>()
142
146
  db,
143
147
  userId: body.userId,
144
148
  email: body.email,
149
+ // §4: 2nd-precedence resolver key (zero-merge stitch). Identity is still
150
+ // enforced via `requireIdentity` (email/userId) above.
151
+ anonymousId: body.anonymousId,
145
152
  contactProperties: body.properties,
146
153
  });
147
154
 
@@ -9,6 +9,14 @@ const eventRequestSchema = z.object({
9
9
  name: z.string().min(1),
10
10
  email: z.string().email().optional(),
11
11
  userId: z.string().min(1).optional(),
12
+ // §4: the caller's analytics anon id (e.g. posthog-js `get_distinct_id()`).
13
+ // 2nd in the resolver's key precedence (`external → email → anonymous →
14
+ // discord`), so when no `external_id` is attached the contact's canonical key
15
+ // BECOMES this value — the browser's own anon events and the server's captures
16
+ // then land on ONE analytics person with zero merge calls. An EXTRA, never a
17
+ // third identity arm: `requireIdentity` still requires email or userId
18
+ // (anon-only public ingest is an abuse vector).
19
+ anonymousId: z.string().min(1).max(200).optional(),
12
20
  eventProperties: z.record(z.string(), z.unknown()).optional(),
13
21
  contactProperties: z.record(z.string(), z.unknown()).optional(),
14
22
  lists: z.record(z.string(), z.boolean()).optional(),
@@ -68,7 +76,7 @@ const eventRoute = createRoute({
68
76
  export const eventsRouter = new OpenAPIHono<AppEnv>().openapi(
69
77
  eventRoute,
70
78
  async (c) => {
71
- const { db, registry, hatchet, logger } = c.get("container");
79
+ const { db, registry, hatchet, logger, analytics } = c.get("container");
72
80
  const body = c.req.valid("json");
73
81
 
74
82
  const guard = requireIdentity(c, body);
@@ -83,10 +91,17 @@ export const eventsRouter = new OpenAPIHono<AppEnv>().openapi(
83
91
  registry,
84
92
  hatchet,
85
93
  logger,
94
+ // §5.3: thread the active analytics provider so a collide-MERGE / key-flip
95
+ // fires the provider-neutral `mergeIdentities` stitch. Absent ⇒ no-op.
96
+ analytics,
86
97
  event: {
87
98
  event: body.name,
88
99
  userId: body.userId,
89
100
  userEmail: body.email,
101
+ // §4: 2nd-precedence resolver key — lets the contact's canonical key
102
+ // equal the browser anon id (zero-merge stitch). Identity is still
103
+ // enforced via `requireIdentity` (email/userId) above.
104
+ anonymousId: body.anonymousId,
90
105
  eventProperties: body.eventProperties ?? {},
91
106
  contactProperties: body.contactProperties,
92
107
  idempotencyKey,
@@ -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
  }
@@ -150,8 +150,15 @@ export const answerRouter = new OpenAPIHono<AppEnv>()
150
150
  );
151
151
  }
152
152
 
153
- const ctx = await resolveEmailSendContext(db, link.emailSendId);
154
- if (ctx) {
153
+ // The answer/comment flow is EMAIL-semantic (it re-ingests a
154
+ // `<event>.comment` keyed on the send). A non-email semantic link has no
155
+ // send to attribute the comment to — `emailSendId` is nullable since the
156
+ // identity-stitching minor, so narrow it here.
157
+ const emailSendId = link.emailSendId;
158
+ const ctx = emailSendId
159
+ ? await resolveEmailSendContext(db, emailSendId)
160
+ : null;
161
+ if (ctx && emailSendId) {
155
162
  // `<event>.comment` is a consumer-namespace event — journeys can wait
156
163
  // on it and destinations receive it like any other. First comment per
157
164
  // (send, event) wins; repeats are no-ops.
@@ -161,7 +168,7 @@ export const answerRouter = new OpenAPIHono<AppEnv>()
161
168
  registry,
162
169
  logger,
163
170
  event: `${link.event}.comment`,
164
- emailSendId: link.emailSendId,
171
+ emailSendId,
165
172
  properties: {
166
173
  comment,
167
174
  parentEvent: link.event,
@@ -169,7 +176,7 @@ export const answerRouter = new OpenAPIHono<AppEnv>()
169
176
  linkId: link.id,
170
177
  },
171
178
  resolvedContext: ctx,
172
- idempotencyKey: `semc:${link.emailSendId}:${link.event}`,
179
+ idempotencyKey: `semc:${emailSendId}:${link.event}`,
173
180
  }).catch((err) => {
174
181
  logger.warn("Failed to ingest answer comment", {
175
182
  linkId: link.id,