@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.21.1",
3
+ "version": "0.23.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,14 +40,14 @@
40
40
  "svix": "^1.95.1",
41
41
  "winston": "^3.19.0",
42
42
  "zod": "^4.4.3",
43
- "@hogsend/core": "^0.21.1",
44
- "@hogsend/db": "^0.21.1",
45
- "@hogsend/email": "^0.21.1",
46
- "@hogsend/plugin-posthog": "^0.21.1",
47
- "@hogsend/plugin-resend": "^0.21.1"
43
+ "@hogsend/core": "^0.23.0",
44
+ "@hogsend/db": "^0.23.0",
45
+ "@hogsend/email": "^0.23.0",
46
+ "@hogsend/plugin-posthog": "^0.23.0",
47
+ "@hogsend/plugin-resend": "^0.23.0"
48
48
  },
49
49
  "optionalDependencies": {
50
- "@hogsend/plugin-postmark": "^0.21.1"
50
+ "@hogsend/plugin-postmark": "^0.23.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^22.15.3",
package/src/app.ts CHANGED
@@ -6,6 +6,7 @@ import { cors } from "hono/cors";
6
6
  import { requestId } from "hono/request-id";
7
7
  import { secureHeaders } from "hono/secure-headers";
8
8
  import type { ErrorHandler, MiddlewareHandler } from "hono/types";
9
+ import { connectorsFromEnv } from "./connectors/presets/index.js";
9
10
  import type { HogsendClient } from "./container.js";
10
11
  import { API_VERSION } from "./env.js";
11
12
  import type { Auth } from "./lib/auth.js";
@@ -15,8 +16,10 @@ import { errorHandler } from "./middleware/error-handler.js";
15
16
  import { clientIpKey, createRateLimit } from "./middleware/rate-limit.js";
16
17
  import { requestLogger } from "./middleware/request-logger.js";
17
18
  import { registerRoutes } from "./routes/index.js";
18
- import type { DefinedWebhookSource } from "./webhook-sources/define-webhook-source.js";
19
- import { presetsFromEnv } from "./webhook-sources/presets/index.js";
19
+ import {
20
+ type DefinedWebhookSource,
21
+ webhookSourceToConnector,
22
+ } from "./webhook-sources/define-webhook-source.js";
20
23
 
21
24
  type AuthSession = Awaited<ReturnType<Auth["api"]["getSession"]>>;
22
25
 
@@ -42,27 +45,18 @@ export interface CreateAppOptions {
42
45
  * Segment) for every preset whose env secret is configured (gated further by
43
46
  * `ENABLED_WEBHOOK_PRESETS`). Consumer-supplied `webhookSources` always win on
44
47
  * an id collision. Set `false` to opt out entirely. Default `true`.
48
+ *
49
+ * @deprecated prefer `createHogsendClient({ enablePresets })` — preset
50
+ * resolution now lives in the container's connector registry. This flag is
51
+ * STILL HONORED end-to-end: when `false`, `createApp` strips the env-preset
52
+ * ids back out of the already-built registry, so the opt-out is NOT a silent
53
+ * no-op.
45
54
  */
46
55
  enablePresets?: boolean;
47
56
  /** Override the default error handler. */
48
57
  onError?: ErrorHandler<AppEnv>;
49
58
  }
50
59
 
51
- /**
52
- * Merge env-enabled presets with the consumer's explicit sources. The
53
- * consumer-supplied source WINS on an id collision (so a hand-tuned override of
54
- * a preset replaces the shipped one rather than registering a duplicate route).
55
- */
56
- function dedupeById(sources: DefinedWebhookSource[]): DefinedWebhookSource[] {
57
- const byId = new Map<string, DefinedWebhookSource>();
58
- for (const source of sources) {
59
- // Last write wins; callers order presets BEFORE consumer sources so the
60
- // consumer override lands last.
61
- byId.set(source.meta.id, source);
62
- }
63
- return [...byId.values()];
64
- }
65
-
66
60
  export function createApp(
67
61
  container: HogsendClient,
68
62
  opts: CreateAppOptions = {},
@@ -130,19 +124,32 @@ export function createApp(
130
124
  return c.json({ needsSetup });
131
125
  });
132
126
 
133
- // Merge env-enabled presets ahead of the consumer's explicit sources so a
134
- // consumer override of a preset id wins (decision #13). `enablePresets`
135
- // defaults true; setting only `STRIPE_WEBHOOK_SECRET` auto-mounts Stripe at
136
- // `POST /v1/webhooks/stripe` and nothing else.
137
- const enablePresets = opts.enablePresets ?? true;
138
- const webhookSources = enablePresets
139
- ? dedupeById([
140
- ...presetsFromEnv(container.env),
141
- ...(opts.webhookSources ?? []),
142
- ])
143
- : (opts.webhookSources ?? []);
144
-
145
- registerRoutes(app, { webhookSources });
127
+ // The container is the single merge point for inbound connectors (env presets
128
+ // + `connectors` + the deprecated `webhookSources` passed to the client).
129
+ // Any `webhookSources` passed HERE (the createApp path) are appended into the
130
+ // installed registry as transport:"webhook" connectors — last-writer-wins,
131
+ // idempotent.
132
+ for (const source of opts.webhookSources ?? []) {
133
+ container.connectorRegistry.register(webhookSourceToConnector(source));
134
+ }
135
+
136
+ // Back-compat: the deprecated `enablePresets: false` STILL suppresses env
137
+ // presets end-to-end. The container builds presets unconditionally when the
138
+ // client wasn't told otherwise, so when this flag is explicitly false we strip
139
+ // the env-preset ids back out of the registry here — but never one a consumer
140
+ // `webhookSources` override re-registered above (those are kept).
141
+ if (opts.enablePresets === false) {
142
+ const overriddenIds = new Set(
143
+ (opts.webhookSources ?? []).map((s) => s.meta.id),
144
+ );
145
+ for (const preset of connectorsFromEnv(container.env)) {
146
+ if (!overriddenIds.has(preset.meta.id)) {
147
+ container.connectorRegistry.unregister(preset.meta.id);
148
+ }
149
+ }
150
+ }
151
+
152
+ registerRoutes(app, { container });
146
153
 
147
154
  // Serve the Studio SPA at /studio/* (static layer, no auth — the SPA gates
148
155
  // itself via /v1/auth/status + login; data endpoints stay behind requireAdmin).
@@ -0,0 +1,205 @@
1
+ import type { Database } from "@hogsend/db";
2
+ import type { z } from "zod";
3
+ import type { env as engineEnv } from "../env.js";
4
+ import type { ConnectorStateIntent } from "../lib/connector-state.js";
5
+ import type { IngestEvent } from "../lib/ingestion.js";
6
+ import type { Logger } from "../lib/logger.js";
7
+ import type {
8
+ SignatureScheme,
9
+ VerifySignatureArgs,
10
+ } from "../webhook-sources/verify.js";
11
+
12
+ /**
13
+ * The unified INBOUND connector abstraction. A connector is one platform's
14
+ * inbound face — it turns a raw platform payload into an {@link IngestEvent}
15
+ * via {@link DefinedConnector.transform}. The transform contract is IDENTICAL
16
+ * across transports; only the ACTIVATION RUNTIME differs:
17
+ *
18
+ * - `"webhook"` — an HTTP route (`POST /v1/webhooks/:id`) verifies the inbound
19
+ * request and calls `transform`. (The long-standing `defineWebhookSource`
20
+ * behavior, now one transport under this umbrella.)
21
+ * - `"gateway"` — a long-lived worker process (its OWN entrypoint / Railway
22
+ * service, NOT a Hatchet task) holds a socket to the platform, and on each
23
+ * platform event POSTs into this connector's own ingress
24
+ * (`POST /v1/connectors/:id/ingress`, shared internal secret) so ALL
25
+ * transform logic stays here and the socket worker stays dumb.
26
+ * - `"poll"` — a Hatchet cron pulls the platform on a schedule (using the
27
+ * stored credential) and runs `transform` per pulled item.
28
+ *
29
+ * The symmetric INBOUND twin of {@link DefinedDestination}.
30
+ * {@link defineConnector} is an identity / validating function.
31
+ */
32
+ export type ConnectorTransport = "webhook" | "gateway" | "poll";
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Auth is TRANSPORT-SHAPED — two distinct concerns, never one union.
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * INBOUND-REQUEST VERIFICATION — webhook transport only. "Does this HTTP
40
+ * request prove it came from the platform?" The EXACT pre-existing
41
+ * `WebhookSourceAuth` union, re-homed here (re-exported by
42
+ * define-webhook-source for back-compat). `match` = shared-secret equality
43
+ * (OPEN when unset); `signature` = provider HMAC (FAIL CLOSED when unset).
44
+ */
45
+ export type InboundVerifyAuth =
46
+ | {
47
+ type: "match";
48
+ header: string;
49
+ envKey: string;
50
+ }
51
+ | {
52
+ type: "signature";
53
+ scheme: SignatureScheme;
54
+ envKey: string;
55
+ header: string;
56
+ fallbackMatchHeader?: string;
57
+ verify?(args: VerifySignatureArgs): boolean | Promise<boolean>;
58
+ };
59
+
60
+ /**
61
+ * STORED OUTBOUND CREDENTIAL — gateway/poll transports only. The connector PULLS
62
+ * from the platform and must present a credential. Declares WHICH
63
+ * `provider_credentials` row to read. The engine resolves the row at activation
64
+ * time and hands the decrypted material to the gateway worker / poll cron —
65
+ * never the transform. SEPARATE from {@link InboundVerifyAuth} by design.
66
+ */
67
+ export interface StoredCredentialRef {
68
+ /** `provider_credentials.providerId` to read (defaults to `meta.id`). */
69
+ providerId?: string;
70
+ /** Which credential kind the runtime resolves. Default `"oauth"`. */
71
+ kind?: "oauth" | "derived";
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Context handed to transform()
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * Side context handed to {@link DefinedConnector.transform}. A superset of the
80
+ * old `WebhookSourceCtx`: `rawBody`/`headers` are populated by the webhook
81
+ * route; `transport` lets a shared transform branch when a platform feeds more
82
+ * than one transport.
83
+ */
84
+ export interface ConnectorCtx {
85
+ db: Database;
86
+ logger: Logger;
87
+ /** Which activation runtime invoked the transform. */
88
+ transport: ConnectorTransport;
89
+ /** Webhook transport only — EXACT raw request bytes. */
90
+ rawBody?: string;
91
+ /** Webhook transport only — inbound request headers (lowercased keys). */
92
+ headers?: Record<string, string>;
93
+ }
94
+
95
+ export interface ConnectorMeta {
96
+ id: string;
97
+ name: string;
98
+ description?: string;
99
+ /** The activation runtime. Defaults to `"webhook"` (back-compat). */
100
+ transport?: ConnectorTransport;
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // OAuth / interactions handlers — the GENERIC engine route surface.
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Minimal handler context for the generic connector routes. `env` is the EXACT
109
+ * validated engine env object (number/boolean fields included) — NOT a string
110
+ * Record — so the route can pass `c.get("container").env` straight through.
111
+ */
112
+ export interface ConnectorRouteCtx {
113
+ db: Database;
114
+ logger: Logger;
115
+ env: typeof engineEnv;
116
+ /** Public base URL of this instance (redirect-URI construction). */
117
+ apiPublicUrl: string;
118
+ }
119
+
120
+ export type ConnectorOAuthResult =
121
+ | { kind: "redirect"; location: string }
122
+ | { kind: "json"; status: number; body: unknown }
123
+ // A self-contained HTML page (e.g. the branded OAuth-fallback success page)
124
+ // the route serves with text/html — NOT JSON-encoded.
125
+ | { kind: "html"; status: number; body: string };
126
+
127
+ export type ConnectorInteractionResult =
128
+ // A non-event handshake the connector already answered (route 200s `body`).
129
+ | { kind: "ack"; status?: number; body?: unknown }
130
+ // An event to push through the ingest pipeline (route ingests + 200s).
131
+ | { kind: "ingest"; event: IngestEvent }
132
+ // Signature failed — route 401s.
133
+ | { kind: "unauthorized" };
134
+
135
+ /**
136
+ * OPTIONAL per-connector OAuth + interactions hooks dispatched by the GENERIC
137
+ * engine routes (mirrors how `/v1/webhooks/:sourceId` dispatches `transform`),
138
+ * so platform #3 needs no new routes.
139
+ *
140
+ * - `oauthCallback` — handle `GET|POST /v1/connectors/:id/oauth/callback`:
141
+ * exchange the code, persist into `provider_credentials`, return a
142
+ * redirect/JSON result. The connector imports the engine's credential-save
143
+ * helpers directly (they are public exports). The ENGINE owns CSRF `state`
144
+ * verification GENERICALLY (the route verifies the signed state before
145
+ * dispatch and hands the decoded {@link ConnectorStateIntent} in as `state`);
146
+ * the handler must NOT re-verify the raw query `state`.
147
+ * - `interactions` — handle `POST /v1/connectors/:id/interactions`: the
148
+ * connector verifies the platform signature ITSELF (Discord ed25519), may
149
+ * 200 a handshake (Discord PING), and may return an {@link IngestEvent}.
150
+ */
151
+ export interface ConnectorHandlers {
152
+ oauthCallback?(args: {
153
+ query: Record<string, string>;
154
+ body: unknown;
155
+ /** Engine-verified, decoded OAuth `state` (CSRF + member-link binding). */
156
+ state: ConnectorStateIntent;
157
+ ctx: ConnectorRouteCtx;
158
+ }): Promise<ConnectorOAuthResult>;
159
+ interactions?(args: {
160
+ rawBody: string;
161
+ headers: Record<string, string>;
162
+ ctx: ConnectorRouteCtx;
163
+ }): Promise<ConnectorInteractionResult>;
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // The umbrella interface
168
+ // ---------------------------------------------------------------------------
169
+
170
+ export interface DefinedConnector<T = unknown> {
171
+ meta: ConnectorMeta;
172
+ /**
173
+ * Inbound-request verification — REQUIRED for `transport: "webhook"`,
174
+ * forbidden otherwise.
175
+ */
176
+ inboundVerify?: InboundVerifyAuth;
177
+ /** Stored outbound credential to pull with — used by `gateway`/`poll`. */
178
+ credential?: StoredCredentialRef;
179
+ /** Optional Zod schema validating the payload BEFORE transform. */
180
+ schema?: z.ZodSchema<T>;
181
+ /** The transport-invariant heart: raw platform payload → IngestEvent | null. */
182
+ transform(payload: T, ctx: ConnectorCtx): Promise<IngestEvent | null>;
183
+ /** OPTIONAL OAuth + interactions hooks for the generic dispatch routes. */
184
+ handlers?: ConnectorHandlers;
185
+ }
186
+
187
+ export function defineConnector<T>(
188
+ def: DefinedConnector<T>,
189
+ ): DefinedConnector<T> {
190
+ // Cheap authoring guard: the two auth concerns are transport-shaped, so a
191
+ // mis-paired definition is a config error worth catching at module load.
192
+ const transport = def.meta.transport ?? "webhook";
193
+ if (transport === "webhook" && !def.inboundVerify) {
194
+ throw new Error(
195
+ `connector "${def.meta.id}" (transport=webhook) must declare inboundVerify`,
196
+ );
197
+ }
198
+ if (transport !== "webhook" && def.inboundVerify) {
199
+ throw new Error(
200
+ `connector "${def.meta.id}" (transport=${transport}) must not declare ` +
201
+ "inboundVerify — gateway/poll pull with a stored credential",
202
+ );
203
+ }
204
+ return def;
205
+ }
@@ -0,0 +1,31 @@
1
+ import type { env as engineEnv } from "../../env.js";
2
+ import { webhookSourceToConnector } from "../../webhook-sources/define-webhook-source.js";
3
+ import {
4
+ PRESET_SOURCES,
5
+ presetsFromEnv,
6
+ } from "../../webhook-sources/presets/index.js";
7
+ import type { DefinedConnector } from "../define-connector.js";
8
+
9
+ /**
10
+ * Every shipped connector preset, keyed by id. Webhook-transport presets are
11
+ * the existing `defineWebhookSource` presets lifted onto the umbrella; gateway/
12
+ * poll presets (Discord) are SHIPPED IN THE PLUGIN, not here — the engine
13
+ * bundles no Discord code.
14
+ */
15
+ export const PRESET_CONNECTORS: Record<string, DefinedConnector> =
16
+ Object.fromEntries(
17
+ Object.values(PRESET_SOURCES).map((s) => [
18
+ s.meta.id,
19
+ webhookSourceToConnector(s),
20
+ ]),
21
+ );
22
+
23
+ /**
24
+ * Resolve which connector presets to register from env. Webhook presets keep
25
+ * their exact env-secret gating (delegated to `presetsFromEnv`). Gateway/poll
26
+ * presets are consumer-supplied via `opts.connectors` (the Discord plugin), so
27
+ * this preset resolver covers only the engine-shipped webhook presets.
28
+ */
29
+ export function connectorsFromEnv(env: typeof engineEnv): DefinedConnector[] {
30
+ return presetsFromEnv(env).map(webhookSourceToConnector);
31
+ }
@@ -0,0 +1,79 @@
1
+ import type {
2
+ ConnectorTransport,
3
+ DefinedConnector,
4
+ } from "./define-connector.js";
5
+ import { PRESET_CONNECTORS } from "./presets/index.js";
6
+
7
+ /**
8
+ * The process-wide connector registry, set once by `createHogsendClient` at
9
+ * startup. Mirrors {@link DestinationRegistry}: keyed by `meta.id`,
10
+ * last-writer-wins on collision, with a lazy preset-only fallback so a
11
+ * self-booting context (a bare poll cron, a test harness) still resolves the
12
+ * shipped presets even before any container ran.
13
+ *
14
+ * Read by: the webhook route (`getByTransport("webhook")`), the generic
15
+ * `/v1/connectors/:id/*` routes (`get(id)` → `handlers`), the poll-cron
16
+ * registrar, and the gateway launcher.
17
+ */
18
+ export class ConnectorRegistry {
19
+ private readonly byId = new Map<string, DefinedConnector>();
20
+
21
+ constructor(connectors: DefinedConnector[] = []) {
22
+ for (const connector of connectors) {
23
+ this.byId.set(connector.meta.id, connector);
24
+ }
25
+ }
26
+
27
+ /** Register / overwrite a connector (last-writer-wins). */
28
+ register(connector: DefinedConnector): void {
29
+ this.byId.set(connector.meta.id, connector);
30
+ }
31
+
32
+ /**
33
+ * Remove a connector by id (no-op when absent). Used by the deprecated
34
+ * `createApp({ enablePresets: false })` strip path to suppress env presets the
35
+ * container already installed.
36
+ */
37
+ unregister(id: string): boolean {
38
+ return this.byId.delete(id);
39
+ }
40
+
41
+ get(id: string): DefinedConnector | undefined {
42
+ return this.byId.get(id);
43
+ }
44
+
45
+ getAll(): DefinedConnector[] {
46
+ return [...this.byId.values()];
47
+ }
48
+
49
+ /** Every connector whose (defaulted) transport matches. */
50
+ getByTransport(transport: ConnectorTransport): DefinedConnector[] {
51
+ return this.getAll().filter(
52
+ (c) => (c.meta.transport ?? "webhook") === transport,
53
+ );
54
+ }
55
+
56
+ count(): number {
57
+ return this.byId.size;
58
+ }
59
+ }
60
+
61
+ let fallback: ConnectorRegistry | undefined;
62
+ let installed: ConnectorRegistry | undefined;
63
+
64
+ export function setConnectorRegistry(registry: ConnectorRegistry): void {
65
+ installed = registry;
66
+ }
67
+
68
+ export function getConnectorRegistry(): ConnectorRegistry {
69
+ if (installed) return installed;
70
+ if (!fallback) {
71
+ fallback = new ConnectorRegistry(Object.values(PRESET_CONNECTORS));
72
+ }
73
+ return fallback;
74
+ }
75
+
76
+ /** Reset the installed registry — only for test cleanup. */
77
+ export function resetConnectorRegistry(): void {
78
+ installed = undefined;
79
+ }
package/src/container.ts CHANGED
@@ -20,6 +20,12 @@ import {
20
20
  buildBucketRegistry,
21
21
  collectBucketReactionJourneys,
22
22
  } from "./buckets/registry.js";
23
+ import type { DefinedConnector } from "./connectors/define-connector.js";
24
+ import { connectorsFromEnv } from "./connectors/presets/index.js";
25
+ import {
26
+ ConnectorRegistry,
27
+ setConnectorRegistry,
28
+ } from "./connectors/registry-singleton.js";
23
29
  import type { DefinedDestination } from "./destinations/define-destination.js";
24
30
  import { destinationsFromEnv } from "./destinations/presets/index.js";
25
31
  import {
@@ -50,6 +56,10 @@ import type {
50
56
  FrequencyCapConfig,
51
57
  } from "./lib/email-service-types.js";
52
58
  import { hatchet } from "./lib/hatchet.js";
59
+ import {
60
+ createIdentityService,
61
+ type IdentityService,
62
+ } from "./lib/identity-service.js";
53
63
  import { createLogger, type Logger } from "./lib/logger.js";
54
64
  import { createTrackedMailer } from "./lib/mailer.js";
55
65
  import { createRedisSecondaryStorage, getRedis } from "./lib/redis.js";
@@ -58,6 +68,10 @@ import { seedPostHogDestination } from "./lib/seed-posthog-destination.js";
58
68
  import { prepareTrackedHtml } from "./lib/tracking.js";
59
69
  import type { DefinedList } from "./lists/define-list.js";
60
70
  import { buildListRegistry, type ListRegistry } from "./lists/registry.js";
71
+ import {
72
+ type DefinedWebhookSource,
73
+ webhookSourceToConnector,
74
+ } from "./webhook-sources/define-webhook-source.js";
61
75
 
62
76
  export interface HogsendDefaults {
63
77
  /** Global fallback IANA timezone for scheduling. Defaults to "UTC". */
@@ -114,6 +128,15 @@ export interface HogsendClient {
114
128
  * treats that as a silent no-op.
115
129
  */
116
130
  analytics?: AnalyticsProvider;
131
+ /**
132
+ * Identity-attach helper that resolves/merges a contact AND propagates the
133
+ * analytics merge (§5.3) in one call, for identity-attach OUTSIDE the
134
+ * `/v1/events` ingest path. Discord `/link` (§7) wires its `resolveContact`
135
+ * callback to `client.identity.linkContact` so a successful contact-merge
136
+ * folds the discord-keyed person into the canonical one through the SAME
137
+ * engine emission ingest uses — never bespoke per-consumer plumbing.
138
+ */
139
+ identity: IdentityService;
117
140
  registry: JourneyRegistry;
118
141
  /**
119
142
  * The bucket registry (id map + event/property inverted indexes for candidate
@@ -130,6 +153,14 @@ export interface HogsendClient {
130
153
  * elsewhere via `getListRegistry()`). Empty when no lists are wired.
131
154
  */
132
155
  listRegistry: ListRegistry;
156
+ /**
157
+ * The unified inbound CONNECTOR registry, keyed by `meta.id`. Holds every
158
+ * transport: webhook (the `:sourceId` dispatch + legacy webhookSources),
159
+ * gateway, and poll. Installed as the process singleton in BOTH the API and
160
+ * worker. The webhook route reads `getByTransport("webhook")`; the generic
161
+ * `/v1/connectors/:id/*` routes read `get(id).handlers`.
162
+ */
163
+ connectorRegistry: ConnectorRegistry;
133
164
  hatchet: HatchetClient;
134
165
  /**
135
166
  * The client repo's migration journal (`migrations/meta/_journal.json`),
@@ -242,6 +273,29 @@ export interface HogsendClientOptions {
242
273
  * worker, so it is wired in both. Defaults to none (presets only).
243
274
  */
244
275
  destinations?: DefinedDestination[];
276
+ /**
277
+ * Code-defined inbound CONNECTORS (the unified umbrella). Each is a
278
+ * `defineConnector()` of any transport. MERGED with `connectorsFromEnv` env
279
+ * presets (consumer LAST ⇒ wins on id collision, mirroring destinations). The
280
+ * legacy `webhookSources` array is folded in here as `transport:"webhook"`
281
+ * connectors. Defaults to none.
282
+ */
283
+ connectors?: DefinedConnector[];
284
+ /**
285
+ * @deprecated pass `connectors` instead. Back-compat array of
286
+ * `defineWebhookSource()` sources; converted to webhook-transport connectors.
287
+ * Still also accepted by `createApp({ webhookSources })`.
288
+ */
289
+ webhookSources?: DefinedWebhookSource[];
290
+ /**
291
+ * Auto-register the shipped webhook-source PRESETS (Clerk, Supabase, Stripe,
292
+ * Segment) for every preset whose env secret is configured (gated further by
293
+ * `ENABLED_WEBHOOK_PRESETS`). Set `false` to suppress env presets entirely.
294
+ * Default `true`. (Mirrors — and is also honored from — the deprecated
295
+ * `createApp({ enablePresets })` flag, which strips preset ids back out of the
296
+ * registry when set there.)
297
+ */
298
+ enablePresets?: boolean;
245
299
  /**
246
300
  * Comma-separated ids (or `*`) controlling which journeys load. Defaults to
247
301
  * `env.ENABLED_JOURNEYS`.
@@ -573,6 +627,13 @@ export function createHogsendClient(
573
627
  // undefined (no provider configured) — the reads stay no-ops.
574
628
  setAnalytics(analytics);
575
629
 
630
+ // Identity-attach helper (§7): bound to THIS container's db + resolved
631
+ // analytics provider so a contact-merge outside the `/v1/events` ingest path
632
+ // (Discord `/link`) propagates the analytics merge through the same engine
633
+ // emission ingest uses. Closes over `analytics` (may be undefined → the merge
634
+ // emission no-ops; the resolve still happens).
635
+ const identity = createIdentityService({ db, analytics, logger });
636
+
576
637
  // Build + install the outbound DESTINATION registry (Phase 3) the
577
638
  // self-booting delivery task resolves by `webhook_endpoints.kind`. Order is
578
639
  // load-bearing: the env-enabled presets come FIRST and the consumer's
@@ -587,6 +648,37 @@ export function createHogsendClient(
587
648
  const destinationRegistry = new DestinationRegistry(destinations);
588
649
  setDestinationRegistry(destinationRegistry);
589
650
 
651
+ // Build + install the unified inbound CONNECTOR registry — the structural
652
+ // twin of the destination registry above. Order is load-bearing (consumer
653
+ // last/wins, mirroring destinations): env presets FIRST (gated by
654
+ // `enablePresets`), then legacy `webhookSources` lifted onto the umbrella,
655
+ // then the first-class `connectors` LAST. Runs in BOTH the API and worker.
656
+ // The webhook route reads `getByTransport("webhook")`; the generic
657
+ // `/v1/connectors/:id/*` routes read `get(id).handlers`. The `email`
658
+ // reserved-id guard is the authoritative one (the route reads the registry).
659
+ const enablePresets = opts.enablePresets ?? true;
660
+ const connectorList = [
661
+ ...(enablePresets ? connectorsFromEnv(env) : []),
662
+ ...(opts.webhookSources ?? []).map(webhookSourceToConnector),
663
+ ...(opts.connectors ?? []),
664
+ ];
665
+ for (const connector of connectorList) {
666
+ if (
667
+ (connector.meta.transport ?? "webhook") === "webhook" &&
668
+ connector.meta.id === "email"
669
+ ) {
670
+ throw new Error(
671
+ 'Connector id "email" is reserved for the email-provider route ' +
672
+ "(POST /v1/webhooks/email/:providerId). Rename the connector.",
673
+ );
674
+ }
675
+ }
676
+ const connectorRegistry = new ConnectorRegistry(connectorList);
677
+ setConnectorRegistry(connectorRegistry);
678
+ logger.debug(
679
+ `Connector registry loaded: ${connectorRegistry.count()} connectors`,
680
+ );
681
+
590
682
  // Optional: auto-seed a PostHog DESTINATION on the outbound spine so the email
591
683
  // lifecycle fans out to PostHog durably. Default OFF (ENABLE_POSTHOG_DESTINATION)
592
684
  // to avoid double-emit alongside the fire-and-forget capture path. Idempotent +
@@ -628,9 +720,11 @@ export function createHogsendClient(
628
720
  templates,
629
721
  analyticsProviders,
630
722
  analytics,
723
+ identity,
631
724
  registry,
632
725
  bucketRegistry,
633
726
  listRegistry,
727
+ connectorRegistry,
634
728
  hatchet: opts.overrides?.hatchet ?? hatchet,
635
729
  clientJournal: opts.clientJournal ?? { entries: [] },
636
730
  defaults,
package/src/env.ts CHANGED
@@ -202,6 +202,11 @@ export const env = createEnv({
202
202
  // Preset enablement override: csv of preset ids, `"*"` (all with a secret),
203
203
  // or `"none"`. Absent → auto-enable any preset whose secret is set.
204
204
  ENABLED_WEBHOOK_PRESETS: z.string().optional(),
205
+ // Shared internal secret authenticating the gateway-worker → connector
206
+ // ingress hop (`POST /v1/connectors/:id/ingress`). Fail-CLOSED when unset
207
+ // (the route 401s), so a gateway worker cannot relay without it. MUST be
208
+ // high-entropy — generate with `openssl rand -base64 32`.
209
+ CONNECTOR_INGRESS_SECRET: z.string().min(32).optional(),
205
210
  // --- Outbound destination presets (Phase 3) ---
206
211
  // Which `defineDestination()` PRESETS are registered into the process
207
212
  // destination registry the delivery task resolves by `endpoint.kind`. csv of