@hogsend/engine 0.21.1 → 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 +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 +73 -0
- package/src/env.ts +5 -0
- package/src/index.ts +56 -0
- package/src/lib/connector-link-codes.ts +218 -0
- package/src/lib/connector-state.ts +114 -0
- package/src/lib/contacts.ts +121 -8
- package/src/lib/discord-gateway-heartbeat.ts +164 -0
- package/src/lib/ingestion.ts +6 -0
- package/src/lib/provider-credentials.ts +11 -0
- 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/index.ts +17 -4
- 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.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.
|
|
44
|
-
"@hogsend/db": "^0.
|
|
45
|
-
"@hogsend/email": "^0.
|
|
46
|
-
"@hogsend/plugin-posthog": "^0.
|
|
47
|
-
"@hogsend/plugin-resend": "^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.
|
|
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
|
|
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 {
|
|
@@ -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 {
|