@hogsend/engine 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.21.0",
3
+ "version": "0.22.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.0",
44
- "@hogsend/db": "^0.21.0",
45
- "@hogsend/email": "^0.21.0",
46
- "@hogsend/plugin-posthog": "^0.21.0",
47
- "@hogsend/plugin-resend": "^0.21.0"
43
+ "@hogsend/core": "^0.22.0",
44
+ "@hogsend/db": "^0.22.0",
45
+ "@hogsend/email": "^0.22.0",
46
+ "@hogsend/plugin-posthog": "^0.22.0",
47
+ "@hogsend/plugin-resend": "^0.22.0"
48
48
  },
49
49
  "optionalDependencies": {
50
- "@hogsend/plugin-postmark": "^0.21.0"
50
+ "@hogsend/plugin-postmark": "^0.22.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 {
@@ -58,6 +64,10 @@ import { seedPostHogDestination } from "./lib/seed-posthog-destination.js";
58
64
  import { prepareTrackedHtml } from "./lib/tracking.js";
59
65
  import type { DefinedList } from "./lists/define-list.js";
60
66
  import { buildListRegistry, type ListRegistry } from "./lists/registry.js";
67
+ import {
68
+ type DefinedWebhookSource,
69
+ webhookSourceToConnector,
70
+ } from "./webhook-sources/define-webhook-source.js";
61
71
 
62
72
  export interface HogsendDefaults {
63
73
  /** Global fallback IANA timezone for scheduling. Defaults to "UTC". */
@@ -130,6 +140,14 @@ export interface HogsendClient {
130
140
  * elsewhere via `getListRegistry()`). Empty when no lists are wired.
131
141
  */
132
142
  listRegistry: ListRegistry;
143
+ /**
144
+ * The unified inbound CONNECTOR registry, keyed by `meta.id`. Holds every
145
+ * transport: webhook (the `:sourceId` dispatch + legacy webhookSources),
146
+ * gateway, and poll. Installed as the process singleton in BOTH the API and
147
+ * worker. The webhook route reads `getByTransport("webhook")`; the generic
148
+ * `/v1/connectors/:id/*` routes read `get(id).handlers`.
149
+ */
150
+ connectorRegistry: ConnectorRegistry;
133
151
  hatchet: HatchetClient;
134
152
  /**
135
153
  * The client repo's migration journal (`migrations/meta/_journal.json`),
@@ -242,6 +260,29 @@ export interface HogsendClientOptions {
242
260
  * worker, so it is wired in both. Defaults to none (presets only).
243
261
  */
244
262
  destinations?: DefinedDestination[];
263
+ /**
264
+ * Code-defined inbound CONNECTORS (the unified umbrella). Each is a
265
+ * `defineConnector()` of any transport. MERGED with `connectorsFromEnv` env
266
+ * presets (consumer LAST ⇒ wins on id collision, mirroring destinations). The
267
+ * legacy `webhookSources` array is folded in here as `transport:"webhook"`
268
+ * connectors. Defaults to none.
269
+ */
270
+ connectors?: DefinedConnector[];
271
+ /**
272
+ * @deprecated pass `connectors` instead. Back-compat array of
273
+ * `defineWebhookSource()` sources; converted to webhook-transport connectors.
274
+ * Still also accepted by `createApp({ webhookSources })`.
275
+ */
276
+ webhookSources?: DefinedWebhookSource[];
277
+ /**
278
+ * Auto-register the shipped webhook-source PRESETS (Clerk, Supabase, Stripe,
279
+ * Segment) for every preset whose env secret is configured (gated further by
280
+ * `ENABLED_WEBHOOK_PRESETS`). Set `false` to suppress env presets entirely.
281
+ * Default `true`. (Mirrors — and is also honored from — the deprecated
282
+ * `createApp({ enablePresets })` flag, which strips preset ids back out of the
283
+ * registry when set there.)
284
+ */
285
+ enablePresets?: boolean;
245
286
  /**
246
287
  * Comma-separated ids (or `*`) controlling which journeys load. Defaults to
247
288
  * `env.ENABLED_JOURNEYS`.
@@ -587,6 +628,37 @@ export function createHogsendClient(
587
628
  const destinationRegistry = new DestinationRegistry(destinations);
588
629
  setDestinationRegistry(destinationRegistry);
589
630
 
631
+ // Build + install the unified inbound CONNECTOR registry — the structural
632
+ // twin of the destination registry above. Order is load-bearing (consumer
633
+ // last/wins, mirroring destinations): env presets FIRST (gated by
634
+ // `enablePresets`), then legacy `webhookSources` lifted onto the umbrella,
635
+ // then the first-class `connectors` LAST. Runs in BOTH the API and worker.
636
+ // The webhook route reads `getByTransport("webhook")`; the generic
637
+ // `/v1/connectors/:id/*` routes read `get(id).handlers`. The `email`
638
+ // reserved-id guard is the authoritative one (the route reads the registry).
639
+ const enablePresets = opts.enablePresets ?? true;
640
+ const connectorList = [
641
+ ...(enablePresets ? connectorsFromEnv(env) : []),
642
+ ...(opts.webhookSources ?? []).map(webhookSourceToConnector),
643
+ ...(opts.connectors ?? []),
644
+ ];
645
+ for (const connector of connectorList) {
646
+ if (
647
+ (connector.meta.transport ?? "webhook") === "webhook" &&
648
+ connector.meta.id === "email"
649
+ ) {
650
+ throw new Error(
651
+ 'Connector id "email" is reserved for the email-provider route ' +
652
+ "(POST /v1/webhooks/email/:providerId). Rename the connector.",
653
+ );
654
+ }
655
+ }
656
+ const connectorRegistry = new ConnectorRegistry(connectorList);
657
+ setConnectorRegistry(connectorRegistry);
658
+ logger.debug(
659
+ `Connector registry loaded: ${connectorRegistry.count()} connectors`,
660
+ );
661
+
590
662
  // Optional: auto-seed a PostHog DESTINATION on the outbound spine so the email
591
663
  // lifecycle fans out to PostHog durably. Default OFF (ENABLE_POSTHOG_DESTINATION)
592
664
  // to avoid double-emit alongside the fire-and-forget capture path. Idempotent +
@@ -631,6 +703,7 @@ export function createHogsendClient(
631
703
  registry,
632
704
  bucketRegistry,
633
705
  listRegistry,
706
+ connectorRegistry,
634
707
  hatchet: opts.overrides?.hatchet ?? hatchet,
635
708
  clientJournal: opts.clientJournal ?? { entries: [] },
636
709
  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
package/src/index.ts CHANGED
@@ -91,6 +91,30 @@ export {
91
91
  resetBucketRegistry,
92
92
  setBucketRegistry,
93
93
  } from "./buckets/registry-singleton.js";
94
+ // --- Inbound connectors: unified authoring layer ---
95
+ export {
96
+ type ConnectorCtx,
97
+ type ConnectorHandlers,
98
+ type ConnectorInteractionResult,
99
+ type ConnectorMeta,
100
+ type ConnectorOAuthResult,
101
+ type ConnectorRouteCtx,
102
+ type ConnectorTransport,
103
+ type DefinedConnector,
104
+ defineConnector,
105
+ type InboundVerifyAuth,
106
+ type StoredCredentialRef,
107
+ } from "./connectors/define-connector.js";
108
+ export {
109
+ connectorsFromEnv,
110
+ PRESET_CONNECTORS,
111
+ } from "./connectors/presets/index.js";
112
+ export {
113
+ ConnectorRegistry,
114
+ getConnectorRegistry,
115
+ resetConnectorRegistry,
116
+ setConnectorRegistry,
117
+ } from "./connectors/registry-singleton.js";
94
118
  export {
95
119
  createHogsendClient,
96
120
  type HogsendClient,
@@ -172,6 +196,28 @@ export {
172
196
  type BucketTransitionSource,
173
197
  emitBucketTransition,
174
198
  } from "./lib/bucket-emit.js";
199
+ // --- Single-use link codes (native connector /link → /verify identify loop) ---
200
+ export {
201
+ type CreateLinkCodeResult,
202
+ createLinkCode,
203
+ generateLinkCode,
204
+ hashLinkCode,
205
+ LINK_CODE_MAX_PER_EMAIL,
206
+ LINK_CODE_MAX_PER_USER,
207
+ LINK_CODE_THROTTLE_WINDOW_SECONDS,
208
+ LINK_CODE_TTL_SECONDS,
209
+ type LinkCodeThrottleScope,
210
+ type RedeemLinkCodeResult,
211
+ redeemLinkCode,
212
+ } from "./lib/connector-link-codes.js";
213
+ // --- Generic signed connector state (CSRF + member-link binding) ---
214
+ export {
215
+ type ConnectorStateIntent,
216
+ signConnectorState,
217
+ verifyConnectorState,
218
+ } from "./lib/connector-state.js";
219
+ // --- Contacts identity (resolve/create — used by connector member-link) ---
220
+ export { resolveOrCreateContact } from "./lib/contacts.js";
175
221
  export {
176
222
  AdminAlreadyExistsError,
177
223
  type CreatedAdmin,
@@ -179,6 +225,12 @@ export {
179
225
  } from "./lib/create-admin.js";
180
226
  // --- Infrastructure singletons ---
181
227
  export { getDb } from "./lib/db.js";
228
+ // --- Discord gateway-worker liveness heartbeat (Studio status) ---
229
+ export {
230
+ type DiscordGatewayHeartbeat,
231
+ getDiscordGatewayHeartbeat,
232
+ startDiscordGatewayHeartbeat,
233
+ } from "./lib/discord-gateway-heartbeat.js";
182
234
  // --- Sending-domain status service (cached; container-held) ---
183
235
  export {
184
236
  createDomainStatusService,
@@ -188,6 +240,7 @@ export {
188
240
  } from "./lib/domain-status.js";
189
241
  // --- Email ---
190
242
  export {
243
+ getEmailService,
191
244
  type SendEmailOptions,
192
245
  type SendEmailResult,
193
246
  sendEmail,
@@ -254,6 +307,7 @@ export {
254
307
  type CredentialKind,
255
308
  type DecryptedProviderCredential,
256
309
  type DerivedCredentialPayload,
310
+ deleteAllProviderCredentials,
257
311
  deleteProviderCredential,
258
312
  getDerivedCredential,
259
313
  getProviderCredential,
@@ -267,6 +321,7 @@ export {
267
321
  export {
268
322
  type AuthSecondaryStorage,
269
323
  createRedisSecondaryStorage,
324
+ getRedis,
270
325
  getRedisIfConnected,
271
326
  } from "./lib/redis.js";
272
327
  // --- Self-service password reset (engine-owned, self-contained email) ---
@@ -338,6 +393,7 @@ export {
338
393
  type WebhookSourceAuth,
339
394
  type WebhookSourceCtx,
340
395
  type WebhookSourceMeta,
396
+ webhookSourceToConnector,
341
397
  } from "./webhook-sources/define-webhook-source.js";
342
398
  // --- Integration presets (Section 2.3/2.4) ---
343
399
  export {