@hogsend/engine 0.8.0 → 0.9.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,78 @@
1
+ import type { DefinedDestination } from "./define-destination.js";
2
+ import { PRESET_DESTINATIONS } from "./presets/index.js";
3
+
4
+ /**
5
+ * The process-wide destination registry, set once by `createHogsendClient` at
6
+ * startup and read by the delivery task (`workflows/deliver-webhook.ts`) to
7
+ * resolve a transform by `endpoint.kind`.
8
+ *
9
+ * Why a singleton (mirrors `lib/analytics-singleton.ts`): the durable
10
+ * `deliverWebhookTask` SELF-BOOTS — it opens its own `getDb()` from
11
+ * `process.env` and has NO client/container reference. So the registered
12
+ * transforms (presets + the consumer's `defineDestination()` destinations) MUST
13
+ * be reachable via a process singleton, exactly as analytics / the journey +
14
+ * bucket registries are. `createHogsendClient` runs in BOTH the API and worker,
15
+ * so by the time any worker task executes the registry has been installed.
16
+ *
17
+ * Resilient default: if a delivery task somehow runs in a process that never
18
+ * called `createHogsendClient` (a bare reaper re-drive in a test harness), the
19
+ * getter lazily falls back to the shipped {@link PRESET_DESTINATIONS} so the
20
+ * no-regression `webhook` + the `posthog` presets still resolve. Installing a
21
+ * registry via {@link setDestinationRegistry} replaces this fallback.
22
+ */
23
+ export class DestinationRegistry {
24
+ private readonly byKind = new Map<string, DefinedDestination>();
25
+
26
+ constructor(destinations: DefinedDestination[] = []) {
27
+ for (const destination of destinations) {
28
+ // Last-writer-wins on id collision — the caller (container) orders the
29
+ // array so the consumer's destination wins over a preset of the same id.
30
+ this.byKind.set(destination.meta.id, destination);
31
+ }
32
+ }
33
+
34
+ /** Resolve a destination by its `kind` id, or `undefined` when unregistered. */
35
+ get(kind: string): DefinedDestination | undefined {
36
+ return this.byKind.get(kind);
37
+ }
38
+
39
+ /** Every registered destination (for diagnostics / catalog enumeration). */
40
+ getAll(): DefinedDestination[] {
41
+ return [...this.byKind.values()];
42
+ }
43
+
44
+ /** Number of registered destinations. */
45
+ count(): number {
46
+ return this.byKind.size;
47
+ }
48
+ }
49
+
50
+ /** The lazily-built fallback registry of just the shipped presets. */
51
+ let fallback: DestinationRegistry | undefined;
52
+ let installed: DestinationRegistry | undefined;
53
+
54
+ /**
55
+ * Install the resolved destination registry. Called by `createHogsendClient`
56
+ * after merging the env presets with the consumer's `opts.destinations`.
57
+ */
58
+ export function setDestinationRegistry(registry: DestinationRegistry): void {
59
+ installed = registry;
60
+ }
61
+
62
+ /**
63
+ * Read the destination registry. Returns the installed registry, or a lazily
64
+ * built preset-only fallback so a self-booting task always resolves the
65
+ * always-on `webhook` + `posthog` presets even before any container ran.
66
+ */
67
+ export function getDestinationRegistry(): DestinationRegistry {
68
+ if (installed) return installed;
69
+ if (!fallback) {
70
+ fallback = new DestinationRegistry(Object.values(PRESET_DESTINATIONS));
71
+ }
72
+ return fallback;
73
+ }
74
+
75
+ /** Reset the installed registry — only for test cleanup. */
76
+ export function resetDestinationRegistry(): void {
77
+ installed = undefined;
78
+ }
package/src/env.ts CHANGED
@@ -57,6 +57,12 @@ export const env = createEnv({
57
57
  POSTHOG_API_KEY: z.string().min(1).optional(),
58
58
  POSTHOG_HOST: z.string().url().optional(),
59
59
  POSTHOG_WEBHOOK_SECRET: z.string().min(1).optional(),
60
+ // When true AND POSTHOG_API_KEY is set, the engine idempotently auto-seeds
61
+ // ONE kind="posthog" webhook endpoint subscribed to the email funnel so the
62
+ // full email lifecycle fans out to PostHog DURABLY (on the delivery spine).
63
+ // Default OFF to avoid a surprise double-emit alongside the existing
64
+ // fire-and-forget PostHog capture path.
65
+ ENABLE_POSTHOG_DESTINATION: z.coerce.boolean().default(false),
60
66
  RESEND_WEBHOOK_SECRET: z.string().min(1).optional(),
61
67
  ADMIN_API_KEY: z.string().min(1).optional(),
62
68
  API_PUBLIC_URL: z.string().url().default("http://localhost:3002"),
@@ -96,6 +102,15 @@ export const env = createEnv({
96
102
  // Preset enablement override: csv of preset ids, `"*"` (all with a secret),
97
103
  // or `"none"`. Absent → auto-enable any preset whose secret is set.
98
104
  ENABLED_WEBHOOK_PRESETS: z.string().optional(),
105
+ // --- Outbound destination presets (Phase 3) ---
106
+ // Which `defineDestination()` PRESETS are registered into the process
107
+ // destination registry the delivery task resolves by `endpoint.kind`. csv of
108
+ // ids (e.g. "segment,slack"), `"*"` (all presets), or `"none"`. Absent → the
109
+ // DEFAULT set (webhook + posthog). The `webhook` and `posthog` presets are
110
+ // ALWAYS registered regardless of this value, so the no-regression delivery
111
+ // path can never be turned off by misconfiguration. Set this to add the
112
+ // segment/slack presets (credentials still live per-endpoint in `config`).
113
+ ENABLED_DESTINATION_PRESETS: z.string().optional(),
99
114
  },
100
115
  runtimeEnv: process.env,
101
116
  emptyStringAsUndefined: true,
package/src/index.ts CHANGED
@@ -76,6 +76,31 @@ export {
76
76
  type HogsendClientOptions,
77
77
  type HogsendDefaults,
78
78
  } from "./container.js";
79
+ // --- Outbound destinations: public authoring layer (Phase 3) ---
80
+ export {
81
+ type DefinedDestination,
82
+ type DestinationCtx,
83
+ type DestinationEnvelope,
84
+ type DestinationMeta,
85
+ type DestinationTransformResult,
86
+ defineDestination,
87
+ type WebhookEndpointRow,
88
+ } from "./destinations/define-destination.js";
89
+ export {
90
+ type DestinationPresetId,
91
+ destinationsFromEnv,
92
+ PRESET_DESTINATIONS,
93
+ posthogDestination,
94
+ segmentDestination,
95
+ slackDestination,
96
+ webhookDestination,
97
+ } from "./destinations/presets/index.js";
98
+ export {
99
+ DestinationRegistry,
100
+ getDestinationRegistry,
101
+ resetDestinationRegistry,
102
+ setDestinationRegistry,
103
+ } from "./destinations/registry-singleton.js";
79
104
  // --- Env ---
80
105
  export { API_VERSION, env } from "./env.js";
81
106
  // --- Journeys ---
@@ -179,7 +179,6 @@ export function defineJourney(options: {
179
179
  hatchetCtx,
180
180
  registry: getJourneyRegistrySingleton(),
181
181
  logger,
182
- posthog,
183
182
  stateId,
184
183
  userId,
185
184
  userEmail,
@@ -7,7 +7,7 @@ import {
7
7
  SleepCondition,
8
8
  UserEventCondition,
9
9
  } from "@hatchet-dev/typescript-sdk/v1/index.js";
10
- import type { DurationObject, PostHogService } from "@hogsend/core";
10
+ import type { DurationObject } from "@hogsend/core";
11
11
  import { durationToMs, evaluateEventCondition } from "@hogsend/core";
12
12
  import type { JourneyRegistry } from "@hogsend/core/registry";
13
13
  import {
@@ -69,7 +69,6 @@ interface JourneyContextConfig {
69
69
  };
70
70
  registry: JourneyRegistry;
71
71
  logger: Logger;
72
- posthog?: PostHogService;
73
72
  stateId: string;
74
73
  userId: string;
75
74
  userEmail: string;
@@ -140,7 +139,6 @@ export function createJourneyContext(
140
139
  hatchetCtx,
141
140
  registry,
142
141
  logger,
143
- posthog,
144
142
  stateId,
145
143
  userId,
146
144
  userEmail,
@@ -316,10 +314,6 @@ export function createJourneyContext(
316
314
  });
317
315
  },
318
316
 
319
- identify(properties) {
320
- posthog?.identify(userId, properties);
321
- },
322
-
323
317
  guard: {
324
318
  async isSubscribed() {
325
319
  const prefs = await checkEmailPreferences({ db, userId });
@@ -390,15 +384,5 @@ export function createJourneyContext(
390
384
  };
391
385
  },
392
386
  },
393
-
394
- posthog: {
395
- capture({ event, properties }) {
396
- posthog?.captureEvent({
397
- distinctId: userId,
398
- event,
399
- properties,
400
- });
401
- },
402
- },
403
387
  };
404
388
  }
@@ -6,6 +6,13 @@ import { createOptionalSingleton } from "./singleton.js";
6
6
  * the module-level task-execution sites that have no client reference of their
7
7
  * own (the journey durable task in `define-journey`, the bucket PostHog sync).
8
8
  *
9
+ * Its role is deliberately NARROW (see the `analytics?` option doc on
10
+ * {@link createHogsendClient}): the identity PULL (`getPersonProperties` for
11
+ * per-user timezone resolution) and the opt-in `bucket.syncToPostHog`
12
+ * person-property mirror. It is explicitly NOT the outbound-catalog firing
13
+ * path — the email/contact/journey/bucket lifecycle fans out durably via
14
+ * DESTINATIONS on the webhook spine, not through this singleton.
15
+ *
9
16
  * Mirrors the journey/bucket-registry + client-schedule-defaults singletons:
10
17
  * `createHogsendClient` runs in BOTH the API and worker processes, so by the
11
18
  * time any worker task executes, the container has already installed the
package/src/lib/mailer.ts CHANGED
@@ -210,9 +210,10 @@ export function createTrackedMailer(
210
210
  case "email.opened":
211
211
  case "email.clicked":
212
212
  // First-party pixel/redirect is the SINGLE outbound emitter for
213
- // open/click (gated on the first-touch nullset UPDATE in the tracking
214
- // routes risk 4). The provider-webhook echo is SUPPRESSED here: it only
215
- // updates the DB status, it does NOT emit outbound (no double-source).
213
+ // open/click it now fires PER-HIT (every open/click a delivery to
214
+ // every destination, owner decision 1). The provider-webhook echo is
215
+ // SUPPRESSED here: it only updates the DB status, it does NOT emit
216
+ // outbound (no double-source).
216
217
  await updateEmailStatus(event.type, event.data.email_id);
217
218
  break;
218
219
  case "email.bounced":
@@ -229,6 +230,9 @@ export function createTrackedMailer(
229
230
  break;
230
231
  case "email.complained":
231
232
  await updateEmailStatus(event.type, event.data.email_id);
233
+ // OUTBOUND `email.complained` — the provider webhook is the SINGLE
234
+ // source for complaints (no first-party signal exists).
235
+ await emitProviderEmailEvent("email.complained", event.data.email_id);
232
236
  await handleComplaint(event.data.to);
233
237
  break;
234
238
  case "email.delivery_delayed":
@@ -279,8 +283,10 @@ export function createTrackedMailer(
279
283
  }
280
284
 
281
285
  /**
282
- * Emit the provider-funnel outbound event (`email.delivered` / `email.bounced`)
283
- * for a Resend `email_id`. Enriches via {@link resolveEmailSendContextByResendId}
286
+ * Emit the provider-funnel outbound event (`email.delivered` /
287
+ * `email.bounced` / `email.complained`) for a Resend `email_id`. These three
288
+ * have no first-party signal — the provider webhook is their single source.
289
+ * Enriches via {@link resolveEmailSendContextByResendId}
284
290
  * (the only handle a provider webhook holds is the Resend id). Fire-and-forget:
285
291
  * a missing context (webhook racing the send-row commit) or a transient outbound
286
292
  * error is logged and swallowed — never failing the webhook handler. No
@@ -288,7 +294,7 @@ export function createTrackedMailer(
288
294
  * shared `Webhook-Id` is the subscriber-side dedup for any provider redelivery.
289
295
  */
290
296
  function emitProviderEmailEvent(
291
- event: "email.delivered" | "email.bounced",
297
+ event: "email.delivered" | "email.bounced" | "email.complained",
292
298
  resendId: string,
293
299
  bounce?: { bounceType?: string; bounceReason?: string },
294
300
  ): void {
@@ -321,6 +327,15 @@ export function createTrackedMailer(
321
327
  },
322
328
  });
323
329
  }
330
+ if (event === "email.complained") {
331
+ return emitOutbound({
332
+ db: database,
333
+ hatchet,
334
+ logger: log,
335
+ event: "email.complained",
336
+ payload: base,
337
+ });
338
+ }
324
339
  return emitOutbound({
325
340
  db: database,
326
341
  hatchet,
@@ -33,6 +33,9 @@ interface EmailEventPayload {
33
33
  userId: string | null;
34
34
  to: string;
35
35
  at: string;
36
+ // Optional enrichment (additive — older subscribers ignore absent keys).
37
+ category?: string;
38
+ subject?: string;
36
39
  }
37
40
 
38
41
  interface BucketEventPayload {
@@ -82,6 +85,10 @@ export interface OutboundPayloads {
82
85
  bounceType?: string;
83
86
  bounceReason?: string;
84
87
  };
88
+ "email.complained": EmailEventPayload & {
89
+ complaintType?: string;
90
+ reason?: string;
91
+ };
85
92
  "journey.completed": {
86
93
  journeyId: string;
87
94
  journeyName: string;
@@ -0,0 +1,93 @@
1
+ import { type Database, webhookEndpoints } from "@hogsend/db";
2
+ import { and, eq, isNull, sql } from "drizzle-orm";
3
+ import type { Logger } from "./logger.js";
4
+
5
+ /**
6
+ * Transaction-scoped advisory-lock key serializing the single-tenant PostHog
7
+ * seed across concurrent API + worker boots. An arbitrary fixed constant within
8
+ * int4 range (the single-arg `pg_advisory_xact_lock` overload casts to bigint).
9
+ */
10
+ const SEED_ADVISORY_LOCK_KEY = 1426198835;
11
+
12
+ /**
13
+ * The email funnel a seeded PostHog destination subscribes to — the full
14
+ * lifecycle that reaches PostHog on NO path before this destination existed.
15
+ */
16
+ const POSTHOG_FUNNEL_EVENTS = [
17
+ "email.sent",
18
+ "email.delivered",
19
+ "email.opened",
20
+ "email.clicked",
21
+ "email.bounced",
22
+ "email.complained",
23
+ ] as const;
24
+
25
+ /**
26
+ * Idempotently seed ONE `kind="posthog"` webhook endpoint subscribed to the
27
+ * email funnel, so the full email lifecycle fans out to PostHog DURABLY on the
28
+ * delivery spine.
29
+ *
30
+ * Guarded against duplicates: it inserts only when no single-tenant
31
+ * (`organization_id IS NULL`) `kind="posthog"` endpoint already exists. Safe to
32
+ * call from BOTH the API and worker boots (both build the client) — the second
33
+ * caller finds the row and no-ops. Fire-and-forget at the call site: a transient
34
+ * seed failure must never block boot.
35
+ */
36
+ export async function seedPostHogDestination(opts: {
37
+ db: Database;
38
+ logger: Logger;
39
+ apiKey: string;
40
+ host?: string;
41
+ }): Promise<{ seeded: boolean }> {
42
+ const { db, logger, apiKey, host } = opts;
43
+
44
+ // Serialize the check-then-insert across concurrent API + worker boots (both
45
+ // build the client) with a transaction-scoped advisory lock, so the race can
46
+ // never double-seed. A per-endpoint unique constraint is intentionally avoided
47
+ // — operators may legitimately create multiple PostHog endpoints by hand.
48
+ return db.transaction(async (tx) => {
49
+ await tx.execute(
50
+ sql`SELECT pg_advisory_xact_lock(${SEED_ADVISORY_LOCK_KEY})`,
51
+ );
52
+
53
+ const existing = await tx
54
+ .select({ id: webhookEndpoints.id })
55
+ .from(webhookEndpoints)
56
+ .where(
57
+ and(
58
+ isNull(webhookEndpoints.organizationId),
59
+ eq(webhookEndpoints.kind, "posthog"),
60
+ ),
61
+ )
62
+ .limit(1);
63
+
64
+ if (existing.length > 0) {
65
+ return { seeded: false };
66
+ }
67
+
68
+ await tx.insert(webhookEndpoints).values({
69
+ url: "posthog://capture",
70
+ description:
71
+ "Auto-seeded PostHog destination (ENABLE_POSTHOG_DESTINATION)",
72
+ kind: "posthog",
73
+ config: {
74
+ apiKey,
75
+ ...(host ? { host } : {}),
76
+ // Preserve continuity with the legacy fire-and-forget PostHog path,
77
+ // which captured clicks as "email.link_clicked"; the posthog transform
78
+ // remaps the canonical "email.clicked" back so existing PostHog funnels
79
+ // keep working after the cutover.
80
+ eventNames: { "email.clicked": "email.link_clicked" },
81
+ },
82
+ eventTypes: [...POSTHOG_FUNNEL_EVENTS],
83
+ secret: null,
84
+ secretPrefix: null,
85
+ disabled: false,
86
+ });
87
+
88
+ logger.info("Seeded PostHog destination on the outbound spine", {
89
+ events: POSTHOG_FUNNEL_EVENTS.length,
90
+ });
91
+ return { seeded: true };
92
+ });
93
+ }
@@ -1,5 +1,4 @@
1
1
  import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
2
- import type { PostHogService } from "@hogsend/core";
3
2
  import type { JourneyRegistry } from "@hogsend/core/registry";
4
3
  import { type Database, emailSends, journeyStates } from "@hogsend/db";
5
4
  import { eq } from "drizzle-orm";
@@ -98,7 +97,6 @@ export interface PushTrackingEventOpts {
98
97
  hatchet: HatchetClient;
99
98
  registry: JourneyRegistry;
100
99
  logger: Logger;
101
- posthog?: PostHogService;
102
100
  event: string;
103
101
  emailSendId: string;
104
102
  properties?: Record<string, unknown>;
@@ -111,10 +109,20 @@ export interface PushTrackingEventOpts {
111
109
  resolvedContext?: EmailSendContext | null;
112
110
  }
113
111
 
112
+ /**
113
+ * Re-push a first-party tracking event (open/click) back onto the INTERNAL bus
114
+ * (`ingestEvent`) for journey routing + `userEvents` persistence.
115
+ *
116
+ * NOTE (Phase 2): this NO LONGER fires a fire-and-forget PostHog `captureEvent`.
117
+ * PostHog now receives opens/clicks PER-HIT via the durable outbound spine — a
118
+ * `kind="posthog"` destination subscribed to `email.opened`/`email.clicked` (the
119
+ * tracking routes call `emitOutbound` alongside this). The legacy double-emit was
120
+ * removed so PostHog gets exactly one, durable copy of each hit.
121
+ */
114
122
  export async function pushTrackingEvent(
115
123
  opts: PushTrackingEventOpts,
116
124
  ): Promise<void> {
117
- const { db, hatchet, registry, logger, posthog, event, emailSendId } = opts;
125
+ const { db, hatchet, registry, logger, event, emailSendId } = opts;
118
126
 
119
127
  const ctx =
120
128
  opts.resolvedContext !== undefined
@@ -128,12 +136,6 @@ export async function pushTrackingEvent(
128
136
  ...opts.properties,
129
137
  };
130
138
 
131
- posthog?.captureEvent({
132
- distinctId: ctx.userId,
133
- event,
134
- properties,
135
- });
136
-
137
139
  await ingestEvent({
138
140
  db,
139
141
  registry,
@@ -25,7 +25,7 @@ import { Webhook } from "svix";
25
25
  */
26
26
 
27
27
  /**
28
- * The 12-event catalog — the SINGLE source of truth (schema, routes, client,
28
+ * The 13-event catalog — the SINGLE source of truth (schema, routes, client,
29
29
  * CLI all derive from this). The `webhook.test` sentinel is intentionally NOT a
30
30
  * member (it is delivered out-of-band regardless of an endpoint's `eventTypes`).
31
31
  */
@@ -39,6 +39,7 @@ export const WEBHOOK_EVENT_TYPES = [
39
39
  "email.opened",
40
40
  "email.clicked",
41
41
  "email.bounced",
42
+ "email.complained",
42
43
  "journey.completed",
43
44
  "bucket.entered",
44
45
  "bucket.left",
@@ -3,6 +3,7 @@ import { webhookDeliveries, webhookEndpoints } from "@hogsend/db";
3
3
  import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
4
4
  import { count, desc, eq } from "drizzle-orm";
5
5
  import type { AppEnv } from "../../app.js";
6
+ import { PRESET_DESTINATIONS } from "../../destinations/presets/index.js";
6
7
  import { errorSchema } from "../../lib/schemas.js";
7
8
  import {
8
9
  generateWebhookSecret,
@@ -24,17 +25,34 @@ import { deliverWebhookTask } from "../../workflows/deliver-webhook.js";
24
25
 
25
26
  // The catalog enum for request validation — derived from the SINGLE source of
26
27
  // truth in `webhook-signing.ts` (Section 1.3). `z.enum` needs a non-empty tuple,
27
- // which `WEBHOOK_EVENT_TYPES` (12 strings, `as const`) satisfies.
28
+ // which `WEBHOOK_EVENT_TYPES` (13 strings, `as const`) satisfies.
28
29
  const eventTypeEnum = z.enum(
29
30
  WEBHOOK_EVENT_TYPES as unknown as [WebhookEventType, ...WebhookEventType[]],
30
31
  );
31
32
 
33
+ // The destination `kind` enum. "webhook" (default) is the signed POST; any
34
+ // other value selects a delivery-time transform adapter keyed by this column.
35
+ // This MUST cover every shipped preset (the skills + env.example tell operators
36
+ // to create segment/slack endpoints via this admin API / the `hs.webhooks` SDK),
37
+ // so it is derived from `PRESET_DESTINATIONS` — the single source of truth for
38
+ // the shipped `kind`s — rather than a hand-maintained literal list. A `kind`
39
+ // whose transform is not REGISTERED at delivery time still fails its delivery as
40
+ // a config error (DLQ); the registry, not the env, decides resolvability — so we
41
+ // accept any shipped preset id here regardless of `ENABLED_DESTINATION_PRESETS`.
42
+ const kindEnum = z.enum(
43
+ Object.keys(PRESET_DESTINATIONS) as unknown as [string, ...string[]],
44
+ );
45
+
32
46
  const webhookEndpointSchema = z.object({
33
47
  id: z.string(),
34
48
  url: z.string(),
35
49
  description: z.string().nullable(),
36
50
  eventTypes: z.array(z.string()),
37
- secretPrefix: z.string(),
51
+ // Keyed destinations have no signing secret (null secretPrefix).
52
+ secretPrefix: z.string().nullable(),
53
+ kind: z.string(),
54
+ // REDACTED config view — credentials (e.g. config.apiKey) are masked.
55
+ config: z.record(z.string(), z.unknown()).nullable(),
38
56
  status: z.enum(["enabled", "disabled"]),
39
57
  organizationId: z.string().nullable(),
40
58
  lastDeliveryAt: z.string().nullable(),
@@ -85,6 +103,11 @@ const createEndpointRoute = createRoute({
85
103
  eventTypes: z.array(eventTypeEnum).min(1),
86
104
  description: z.string().max(500).optional(),
87
105
  disabled: z.boolean().optional(),
106
+ // Destination selector. Defaults to "webhook" (signed POST).
107
+ kind: kindEnum.optional(),
108
+ // Per-destination config for keyed adapters (e.g. PostHog's
109
+ // `{ apiKey, host }`). Ignored for kind="webhook".
110
+ config: z.record(z.string(), z.unknown()).optional(),
88
111
  }),
89
112
  },
90
113
  },
@@ -94,7 +117,11 @@ const createEndpointRoute = createRoute({
94
117
  201: {
95
118
  content: {
96
119
  "application/json": {
97
- schema: webhookEndpointSchema.extend({ secret: z.string() }),
120
+ // `secret` is present ONLY for kind="webhook" (the one-time signing
121
+ // secret). Keyed destinations carry no secret.
122
+ schema: webhookEndpointSchema.extend({
123
+ secret: z.string().optional(),
124
+ }),
98
125
  },
99
126
  },
100
127
  description: "Endpoint created — signing secret shown only once",
@@ -139,6 +166,8 @@ const updateEndpointRoute = createRoute({
139
166
  eventTypes: z.array(eventTypeEnum).min(1).optional(),
140
167
  description: z.string().max(500).nullable().optional(),
141
168
  disabled: z.boolean().optional(),
169
+ kind: kindEnum.optional(),
170
+ config: z.record(z.string(), z.unknown()).nullable().optional(),
142
171
  }),
143
172
  },
144
173
  },
@@ -237,10 +266,30 @@ const testRoute = createRoute({
237
266
  },
238
267
  });
239
268
 
269
+ /** Config keys whose values are credentials and must be masked in responses. */
270
+ const REDACTED_CONFIG_KEYS = new Set(["apiKey", "apikey", "api_key"]);
271
+
272
+ /**
273
+ * Mask credential values in a keyed destination's `config` for API responses —
274
+ * `config.apiKey` becomes `"***"` so a list/get never leaks the secret. Returns
275
+ * null when there is no config (kind="webhook").
276
+ */
277
+ function redactConfig(
278
+ config: Record<string, unknown> | null,
279
+ ): Record<string, unknown> | null {
280
+ if (!config) return null;
281
+ const out: Record<string, unknown> = {};
282
+ for (const [key, value] of Object.entries(config)) {
283
+ out[key] = REDACTED_CONFIG_KEYS.has(key) ? "***" : value;
284
+ }
285
+ return out;
286
+ }
287
+
240
288
  /**
241
289
  * Serialize an endpoint row for an API response. NEVER includes `secret` — the
242
290
  * full `whsec_…` is surfaced only on create + rotate-secret via the dedicated
243
- * response shapes. `status` is derived from the `disabled` boolean.
291
+ * response shapes. `config` is REDACTED (credentials masked). `status` is
292
+ * derived from the `disabled` boolean.
244
293
  */
245
294
  function serializeEndpoint(row: typeof webhookEndpoints.$inferSelect) {
246
295
  return {
@@ -249,6 +298,8 @@ function serializeEndpoint(row: typeof webhookEndpoints.$inferSelect) {
249
298
  description: row.description,
250
299
  eventTypes: row.eventTypes as string[],
251
300
  secretPrefix: row.secretPrefix,
301
+ kind: row.kind,
302
+ config: redactConfig(row.config),
252
303
  status: (row.disabled ? "disabled" : "enabled") as "enabled" | "disabled",
253
304
  organizationId: row.organizationId,
254
305
  lastDeliveryAt: row.lastDeliveryAt?.toISOString() ?? null,
@@ -292,7 +343,13 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
292
343
  const { db } = c.get("container");
293
344
  const body = c.req.valid("json");
294
345
 
295
- const { secret, secretPrefix } = generateWebhookSecret();
346
+ const kind = body.kind ?? "webhook";
347
+ // Only the signed "webhook" kind gets a signing secret; keyed destinations
348
+ // authenticate via `config` (their secret/secretPrefix stay null).
349
+ const credentials =
350
+ kind === "webhook"
351
+ ? generateWebhookSecret()
352
+ : { secret: null, secretPrefix: null };
296
353
 
297
354
  const [created] = await db
298
355
  .insert(webhookEndpoints)
@@ -301,15 +358,24 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
301
358
  eventTypes: body.eventTypes,
302
359
  description: body.description ?? null,
303
360
  disabled: body.disabled ?? false,
304
- secret,
305
- secretPrefix,
361
+ kind,
362
+ config: body.config ?? null,
363
+ secret: credentials.secret,
364
+ secretPrefix: credentials.secretPrefix,
306
365
  })
307
366
  .returning();
308
367
 
309
368
  if (!created) throw new Error("Failed to create webhook endpoint");
310
369
 
311
- // The ONLY list/get-shaped response that also carries the full secret.
312
- return c.json({ ...serializeEndpoint(created), secret }, 201);
370
+ // The ONLY list/get-shaped response that also carries the full secret — and
371
+ // ONLY for kind="webhook" (keyed destinations have no secret to surface).
372
+ if (kind === "webhook" && credentials.secret) {
373
+ return c.json(
374
+ { ...serializeEndpoint(created), secret: credentials.secret },
375
+ 201,
376
+ );
377
+ }
378
+ return c.json(serializeEndpoint(created), 201);
313
379
  })
314
380
  .openapi(getEndpointRoute, async (c) => {
315
381
  const { db } = c.get("container");
@@ -349,6 +415,19 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
349
415
  if (body.eventTypes !== undefined) patch.eventTypes = body.eventTypes;
350
416
  if (body.description !== undefined) patch.description = body.description;
351
417
  if (body.disabled !== undefined) patch.disabled = body.disabled;
418
+ if (body.kind !== undefined) patch.kind = body.kind;
419
+ if (body.config !== undefined) patch.config = body.config;
420
+
421
+ // Foot-gun guard: an endpoint that ends up as a signed "webhook" with no
422
+ // secret would dead-letter every delivery (the webhook transform signs with
423
+ // the live secret). Mint one when switching to (or back to) kind="webhook"
424
+ // without an existing secret. Keyed kinds keep their null secret — harmless.
425
+ const effectiveKind = body.kind ?? existing.kind;
426
+ if (effectiveKind === "webhook" && !existing.secret) {
427
+ const { secret, secretPrefix } = generateWebhookSecret();
428
+ patch.secret = secret;
429
+ patch.secretPrefix = secretPrefix;
430
+ }
352
431
 
353
432
  const [updated] = await db
354
433
  .update(webhookEndpoints)
@@ -418,6 +497,14 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
418
497
  // endpoint's `eventTypes`. Build a synthetic delivery row directly — it does
419
498
  // NOT go through `emitOutbound` (which filters by subscription) — then enqueue
420
499
  // the same durable delivery task the live emit path uses.
500
+ //
501
+ // The synthetic envelope routes THROUGH the per-kind delivery adapter (the
502
+ // delivery task resolves it by `endpoint.kind`), so the `data` must carry the
503
+ // fields every adapter needs to build a VALID request. For `kind="webhook"`
504
+ // the adapter only signs the frozen bytes, so the extra fields are harmless;
505
+ // for `kind="posthog"` the capture adapter coalesces `distinct_id` from
506
+ // `userId`/`to`/`userEmail`, so a synthetic recipient (`to`) is required or
507
+ // the test would POST a malformed capture with no distinct_id.
421
508
  const webhookId = `msg_${randomUUID()}`;
422
509
  const timestamp = new Date();
423
510
  const envelope = {
@@ -428,6 +515,10 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
428
515
  message: "Hogsend test event",
429
516
  endpointId: endpoint.id,
430
517
  sentAt: timestamp.toISOString(),
518
+ // Adapter-friendly synthetic identity so a keyed destination (e.g.
519
+ // posthog) builds a valid request from the test envelope.
520
+ userId: `test_${endpoint.id}`,
521
+ to: "test@hogsend.com",
431
522
  },
432
523
  };
433
524