@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 +7 -7
- package/src/app.ts +37 -30
- package/src/connectors/define-connector.ts +205 -0
- package/src/connectors/presets/index.ts +31 -0
- package/src/connectors/registry-singleton.ts +79 -0
- package/src/container.ts +94 -0
- package/src/env.ts +5 -0
- package/src/index.ts +69 -0
- package/src/lib/analytics-identity.ts +112 -0
- package/src/lib/connector-link-codes.ts +218 -0
- package/src/lib/connector-state.ts +114 -0
- package/src/lib/contacts.ts +233 -26
- package/src/lib/discord-gateway-heartbeat.ts +164 -0
- package/src/lib/identity-service.ts +107 -0
- package/src/lib/identity-token.ts +65 -5
- package/src/lib/ingestion.ts +58 -2
- package/src/lib/outbound.ts +17 -0
- package/src/lib/provider-credentials.ts +11 -0
- package/src/lib/semantic-click.ts +15 -6
- package/src/lib/tracking-events.ts +5 -1
- package/src/lib/tracking.ts +37 -0
- package/src/lib/webhook-signing.ts +7 -1
- package/src/routes/admin/connectors.ts +466 -0
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/connectors/index.ts +279 -0
- package/src/routes/contacts/index.ts +7 -0
- package/src/routes/events/index.ts +16 -1
- package/src/routes/index.ts +17 -4
- package/src/routes/tracking/answer.ts +11 -4
- package/src/routes/tracking/click.ts +130 -71
- package/src/routes/tracking/identify.ts +62 -15
- package/src/routes/webhooks/index.ts +3 -3
- package/src/routes/webhooks/sources.ts +20 -10
- package/src/webhook-sources/define-webhook-source.ts +44 -39
- package/src/webhook-sources/verify.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
44
|
-
"@hogsend/db": "^0.
|
|
45
|
-
"@hogsend/email": "^0.
|
|
46
|
-
"@hogsend/plugin-posthog": "^0.
|
|
47
|
-
"@hogsend/plugin-resend": "^0.
|
|
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.
|
|
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
|
|
19
|
-
|
|
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
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
const webhookSources
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|