@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/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,
@@ -141,6 +165,11 @@ export {
141
165
  setJourneyRegistry,
142
166
  } from "./journeys/registry-singleton.js";
143
167
  // --- Analytics provider registry (the analytics sibling) ---
168
+ export {
169
+ type IdentityMergeReason,
170
+ logResidualTwins,
171
+ mergeAnalyticsIdentities,
172
+ } from "./lib/analytics-identity.js";
144
173
  export { AnalyticsProviderRegistry } from "./lib/analytics-provider-registry.js";
145
174
  export { analyticsProvidersFromEnv } from "./lib/analytics-providers-from-env.js";
146
175
  // --- Auth ---
@@ -172,6 +201,28 @@ export {
172
201
  type BucketTransitionSource,
173
202
  emitBucketTransition,
174
203
  } from "./lib/bucket-emit.js";
204
+ // --- Single-use link codes (native connector /link → /verify identify loop) ---
205
+ export {
206
+ type CreateLinkCodeResult,
207
+ createLinkCode,
208
+ generateLinkCode,
209
+ hashLinkCode,
210
+ LINK_CODE_MAX_PER_EMAIL,
211
+ LINK_CODE_MAX_PER_USER,
212
+ LINK_CODE_THROTTLE_WINDOW_SECONDS,
213
+ LINK_CODE_TTL_SECONDS,
214
+ type LinkCodeThrottleScope,
215
+ type RedeemLinkCodeResult,
216
+ redeemLinkCode,
217
+ } from "./lib/connector-link-codes.js";
218
+ // --- Generic signed connector state (CSRF + member-link binding) ---
219
+ export {
220
+ type ConnectorStateIntent,
221
+ signConnectorState,
222
+ verifyConnectorState,
223
+ } from "./lib/connector-state.js";
224
+ // --- Contacts identity (resolve/create — used by connector member-link) ---
225
+ export { resolveOrCreateContact } from "./lib/contacts.js";
175
226
  export {
176
227
  AdminAlreadyExistsError,
177
228
  type CreatedAdmin,
@@ -179,6 +230,12 @@ export {
179
230
  } from "./lib/create-admin.js";
180
231
  // --- Infrastructure singletons ---
181
232
  export { getDb } from "./lib/db.js";
233
+ // --- Discord gateway-worker liveness heartbeat (Studio status) ---
234
+ export {
235
+ type DiscordGatewayHeartbeat,
236
+ getDiscordGatewayHeartbeat,
237
+ startDiscordGatewayHeartbeat,
238
+ } from "./lib/discord-gateway-heartbeat.js";
182
239
  // --- Sending-domain status service (cached; container-held) ---
183
240
  export {
184
241
  createDomainStatusService,
@@ -188,6 +245,7 @@ export {
188
245
  } from "./lib/domain-status.js";
189
246
  // --- Email ---
190
247
  export {
248
+ getEmailService,
191
249
  type SendEmailOptions,
192
250
  type SendEmailResult,
193
251
  sendEmail,
@@ -212,9 +270,16 @@ export { checkEmailPreferences } from "./lib/enrollment-guards.js";
212
270
  export { isFrequencyCapped } from "./lib/frequency-cap.js";
213
271
  export { addrSpecOf, hostOfFromAddress } from "./lib/from-address.js";
214
272
  export { hatchet } from "./lib/hatchet.js";
273
+ // --- Identity service (resolve/merge + analytics merge propagation, §7) ---
274
+ export {
275
+ createIdentityService,
276
+ type IdentityService,
277
+ type LinkContactArgs,
278
+ } from "./lib/identity-service.js";
215
279
  export {
216
280
  generateIdentityToken,
217
281
  type IdentityTokenPayload,
282
+ type IdentityTokenScope,
218
283
  InvalidIdentityTokenError,
219
284
  validateIdentityToken,
220
285
  } from "./lib/identity-token.js";
@@ -254,6 +319,7 @@ export {
254
319
  type CredentialKind,
255
320
  type DecryptedProviderCredential,
256
321
  type DerivedCredentialPayload,
322
+ deleteAllProviderCredentials,
257
323
  deleteProviderCredential,
258
324
  getDerivedCredential,
259
325
  getProviderCredential,
@@ -267,6 +333,7 @@ export {
267
333
  export {
268
334
  type AuthSecondaryStorage,
269
335
  createRedisSecondaryStorage,
336
+ getRedis,
270
337
  getRedisIfConnected,
271
338
  } from "./lib/redis.js";
272
339
  // --- Self-service password reset (engine-owned, self-contained email) ---
@@ -295,6 +362,7 @@ export {
295
362
  } from "./lib/tracked.js";
296
363
  // --- Tracking ---
297
364
  export {
365
+ createTrackedLink,
298
366
  injectOpenPixel,
299
367
  prepareTrackedHtml,
300
368
  rewriteLinks,
@@ -338,6 +406,7 @@ export {
338
406
  type WebhookSourceAuth,
339
407
  type WebhookSourceCtx,
340
408
  type WebhookSourceMeta,
409
+ webhookSourceToConnector,
341
410
  } from "./webhook-sources/define-webhook-source.js";
342
411
  // --- Integration presets (Section 2.3/2.4) ---
343
412
  export {
@@ -0,0 +1,112 @@
1
+ import type { AnalyticsProvider } from "@hogsend/core";
2
+ import type { Logger } from "./logger.js";
3
+
4
+ /**
5
+ * The reason a merge was emitted — surfaced on the `identity.merge.emitted`
6
+ * structured log so an operator can see WHICH resolver path stitched (§10.5). A
7
+ * declining `collide_merge` / `key_flip` volume after anon threading lands
8
+ * (Stage 1) is the empirical "forks prevented" signal.
9
+ */
10
+ export type IdentityMergeReason =
11
+ | "collide_merge"
12
+ | "key_flip"
13
+ | "click_identify"
14
+ | "discord_link";
15
+
16
+ /**
17
+ * Fan out the provider-neutral `mergeIdentities` primitive (§5.3) once per
18
+ * loser key, folding each absorbed (anonymous/uuid) key INTO the surviving
19
+ * canonical contact key. Fire-and-forget and never throws: analytics is
20
+ * non-load-bearing, so a provider error must not fail the ingest that triggered
21
+ * the merge.
22
+ *
23
+ * Direction is load-bearing (MF-1): `survivorKey` is the SURVIVING/canonical
24
+ * (identified) id and each `loserKey` is the ABSORBED (anonymous) one — mapped
25
+ * straight to `mergeIdentities({ distinctId: survivorKey, alias: loserKey })`.
26
+ *
27
+ * No-ops cleanly when:
28
+ * - no provider is injected (`!analytics`),
29
+ * - the active provider can't merge (`!capabilities.identityMerge` — a legacy
30
+ * adapter or a provider without an `alias` wire),
31
+ * - or it carries no `mergeIdentities` method.
32
+ *
33
+ * MF-2: callers MUST pass only the SAFE-to-absorb loser keys (anonymous/uuid,
34
+ * never an `external_id` that already identified a PostHog person). Aliasing an
35
+ * already-identified key is the identified→identified merge PostHog refuses
36
+ * (R2/R4) — it silently no-ops AND spams "Refused to merge" warnings on the
37
+ * normal merge path. The filtering happens at the emission point (the resolver
38
+ * splits its loser keys into safe vs. identified); this helper only fans out
39
+ * what it is given and skips a self-alias (`loserKey === survivorKey`).
40
+ */
41
+ export function mergeAnalyticsIdentities(opts: {
42
+ analytics?: AnalyticsProvider;
43
+ survivorKey: string;
44
+ loserKeys: string[];
45
+ /** Stitching path, for the `identity.merge.emitted` observability log. */
46
+ reason: IdentityMergeReason;
47
+ /** The contact id, for correlating the merge log to a contact row. */
48
+ contactId?: string;
49
+ logger?: Logger;
50
+ }): void {
51
+ const { analytics, survivorKey, loserKeys, reason, contactId, logger } = opts;
52
+
53
+ if (!analytics?.capabilities.identityMerge) {
54
+ if (loserKeys.length > 0) {
55
+ logger?.debug("identity.merge.skipped", {
56
+ reason: analytics ? "no_capability" : "no_provider",
57
+ });
58
+ }
59
+ return;
60
+ }
61
+ if (!analytics.mergeIdentities) return;
62
+
63
+ for (const loserKey of loserKeys) {
64
+ if (!loserKey || loserKey === survivorKey) {
65
+ logger?.debug("identity.merge.skipped", { reason: "self_alias" });
66
+ continue;
67
+ }
68
+ try {
69
+ analytics.mergeIdentities({ distinctId: survivorKey, alias: loserKey });
70
+ logger?.info("identity.merge.emitted", {
71
+ provider: analytics.meta.id,
72
+ survivorKey,
73
+ alias: loserKey,
74
+ reason,
75
+ ...(contactId ? { contactId } : {}),
76
+ });
77
+ } catch (err) {
78
+ // Best-effort: analytics is non-load-bearing — never throw.
79
+ logger?.warn("identity.merge.failed", {
80
+ provider: analytics.meta.id,
81
+ reason,
82
+ error: err instanceof Error ? err.message : String(err),
83
+ });
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Emit the `identity.merge.residual_twin` observability log (§10.5) for each
90
+ * loser key MF-2 excluded from the safe fan-out: a loser carrying an
91
+ * `external_id` is, by the engine's own model, an already-identified PostHog
92
+ * person, and PostHog refuses to merge two identified persons on the safe path.
93
+ * These twins are the known steady-state residual (OQ-1) made visible — NOT an
94
+ * error, just the honest "one email → one person, except across two prior
95
+ * identified persons" outcome surfaced for monitoring.
96
+ */
97
+ export function logResidualTwins(opts: {
98
+ survivorKey: string;
99
+ identifiedLoserKeys: string[];
100
+ contactId?: string;
101
+ logger?: Logger;
102
+ }): void {
103
+ const { survivorKey, identifiedLoserKeys, contactId, logger } = opts;
104
+ for (const loserExternalId of identifiedLoserKeys) {
105
+ if (!loserExternalId || loserExternalId === survivorKey) continue;
106
+ logger?.info("identity.merge.residual_twin", {
107
+ survivorKey,
108
+ loserExternalId,
109
+ ...(contactId ? { contactId } : {}),
110
+ });
111
+ }
112
+ }
@@ -0,0 +1,218 @@
1
+ import { createHash, randomInt } from "node:crypto";
2
+ import { connectorLinkCodes, type Database } from "@hogsend/db";
3
+ import { and, count, eq, gte, isNull, sql } from "drizzle-orm";
4
+ import { safeEqual } from "../webhook-sources/verify.js";
5
+
6
+ /**
7
+ * Single-use verification codes for the native in-connector identify loop
8
+ * (Discord `/link <email>` → emailed code → `/verify <code>`).
9
+ *
10
+ * Security posture (all four hold simultaneously — the 6-digit code's small
11
+ * keyspace is acceptable ONLY because of this stack):
12
+ * - SINGLE-USE — redeem is an atomic `UPDATE … SET used_at WHERE used_at IS
13
+ * NULL RETURNING`; a second redeem of the same code affects zero rows.
14
+ * - TTL'd — `expires_at` is checked on redeem; an aged code is rejected.
15
+ * - IDENTITY-BOUND — the code is bound to the invoking platform user at mint
16
+ * and re-checked at redeem with a CONSTANT-TIME compare; a code minted for
17
+ * one account can never be redeemed by another.
18
+ * - HASHED-AT-REST — only `sha256(code)` is stored; the plaintext code lives
19
+ * only in the member's inbox, so a DB read never yields a redeemable code.
20
+ * - THROTTLED — the anti-email-bomb throttle counts mints in a rolling window.
21
+ * The per-USER cap ({@link LINK_CODE_MAX_PER_USER}) is the PRIMARY backstop:
22
+ * it caps how many codes ONE Discord account can trigger regardless of
23
+ * address. The per-EMAIL cap ({@link LINK_CODE_MAX_PER_EMAIL}) is a
24
+ * SECONDARY, BEST-EFFORT speed bump — `+tag`/dot aliasing sidesteps it
25
+ * (`a@x.com`, `a+1@x.com`, `a.@x.com` normalize to DISTINCT `targetEmail`
26
+ * buckets under `trim().toLowerCase()`, which does NOT canonicalize
27
+ * Gmail-style aliases; canonicalizing is out of scope). Either cap, when hit,
28
+ * refuses to mint+send.
29
+ */
30
+
31
+ /** How long a minted code is valid before `/verify` (15 minutes). */
32
+ export const LINK_CODE_TTL_SECONDS = 900;
33
+
34
+ /** Rolling window (seconds) the anti-email-bomb throttle counts mints over. */
35
+ export const LINK_CODE_THROTTLE_WINDOW_SECONDS = 900;
36
+
37
+ /** Max codes one invoking platform user may mint per throttle window. */
38
+ export const LINK_CODE_MAX_PER_USER = 5;
39
+
40
+ /** Max codes that may be minted FOR one target email per throttle window. */
41
+ export const LINK_CODE_MAX_PER_EMAIL = 3;
42
+
43
+ /** sha256 of the plaintext code, lowercase hex — the at-rest lookup key. */
44
+ export function hashLinkCode(code: string): string {
45
+ return createHash("sha256").update(code, "utf8").digest("hex");
46
+ }
47
+
48
+ /**
49
+ * A 6-digit, human-typable, zero-padded code drawn from a CSPRNG. Entropy is
50
+ * intentionally low (≈20 bits) — the security comes from single-use + TTL +
51
+ * identity-binding + throttling, NOT from the code being hard to guess (a
52
+ * guesser must hit a code minted FOR THEIR OWN platform id in a 15-min window,
53
+ * which is self-targeting and pointless). 6 digits is the universal
54
+ * "verification code" UX, trivial to type on mobile.
55
+ */
56
+ export function generateLinkCode(): string {
57
+ return String(randomInt(0, 1_000_000)).padStart(6, "0");
58
+ }
59
+
60
+ /** Why a `/link` mint was refused. */
61
+ export type LinkCodeThrottleScope = "platformUser" | "email";
62
+
63
+ export type CreateLinkCodeResult =
64
+ | { ok: true; code: string }
65
+ | { ok: false; reason: "throttled"; scope: LinkCodeThrottleScope };
66
+
67
+ /**
68
+ * Mint a single-use link code, enforcing the anti-email-bomb throttle FIRST.
69
+ *
70
+ * The throttle counts rows already minted for this connector in the rolling
71
+ * window — per invoking `platformUserId` (≤ {@link LINK_CODE_MAX_PER_USER}) AND
72
+ * per target `email` (≤ {@link LINK_CODE_MAX_PER_EMAIL}) — and refuses to mint
73
+ * (so nothing is emailed) when either cap is already met. Counting on MINT and
74
+ * never freeing on redeem/expiry is the email-bomb control. On success the row
75
+ * stores ONLY the sha256 hash and returns the plaintext `code` for the caller
76
+ * to email.
77
+ *
78
+ * NOTE: a DB failure throws — callers MUST treat that as a hard failure and
79
+ * NOT fall through to sending an email (an unthrottled send would defeat the
80
+ * email-bomb control).
81
+ */
82
+ export async function createLinkCode(opts: {
83
+ db: Database;
84
+ connectorId: string;
85
+ platformUserId: string;
86
+ email: string;
87
+ ttlSeconds?: number;
88
+ maxPerUser?: number;
89
+ maxPerEmail?: number;
90
+ windowSeconds?: number;
91
+ }): Promise<CreateLinkCodeResult> {
92
+ const {
93
+ db,
94
+ connectorId,
95
+ platformUserId,
96
+ ttlSeconds = LINK_CODE_TTL_SECONDS,
97
+ maxPerUser = LINK_CODE_MAX_PER_USER,
98
+ maxPerEmail = LINK_CODE_MAX_PER_EMAIL,
99
+ windowSeconds = LINK_CODE_THROTTLE_WINDOW_SECONDS,
100
+ } = opts;
101
+ // Normalize the email so the per-email throttle + the stored resolution key
102
+ // are case-insensitive (mirrors the contacts email normalization).
103
+ const email = opts.email.trim().toLowerCase();
104
+
105
+ const since = new Date(Date.now() - windowSeconds * 1000);
106
+
107
+ // Per-invoking-user throttle: caps one account spamming many addresses.
108
+ const [userRow] = await db
109
+ .select({ n: count() })
110
+ .from(connectorLinkCodes)
111
+ .where(
112
+ and(
113
+ eq(connectorLinkCodes.connectorId, connectorId),
114
+ eq(connectorLinkCodes.platformUserId, platformUserId),
115
+ gte(connectorLinkCodes.createdAt, since),
116
+ ),
117
+ );
118
+ if ((userRow?.n ?? 0) >= maxPerUser) {
119
+ return { ok: false, reason: "throttled", scope: "platformUser" };
120
+ }
121
+
122
+ // Per-target-email throttle: caps bombing one victim across many accounts.
123
+ const [emailRow] = await db
124
+ .select({ n: count() })
125
+ .from(connectorLinkCodes)
126
+ .where(
127
+ and(
128
+ eq(connectorLinkCodes.connectorId, connectorId),
129
+ eq(connectorLinkCodes.targetEmail, email),
130
+ gte(connectorLinkCodes.createdAt, since),
131
+ ),
132
+ );
133
+ if ((emailRow?.n ?? 0) >= maxPerEmail) {
134
+ return { ok: false, reason: "throttled", scope: "email" };
135
+ }
136
+
137
+ const code = generateLinkCode();
138
+ const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
139
+
140
+ await db.insert(connectorLinkCodes).values({
141
+ connectorId,
142
+ codeHash: hashLinkCode(code),
143
+ platformUserId,
144
+ targetEmail: email,
145
+ expiresAt,
146
+ });
147
+
148
+ return { ok: true, code };
149
+ }
150
+
151
+ export type RedeemLinkCodeResult =
152
+ | { ok: true; email: string }
153
+ | { ok: false; reason: "invalid" | "expired" | "used" | "wrong_user" };
154
+
155
+ /**
156
+ * Redeem a typed code for the bound email — single-use, TTL-enforced, and
157
+ * identity-bound to the invoking platform user.
158
+ *
159
+ * Resolution is by `sha256(code)` (the plaintext is never stored). A missing
160
+ * row → `invalid`. A row whose `platformUserId` does not match the caller
161
+ * (CONSTANT-TIME compared so a redeem can't probe other accounts' codes) →
162
+ * `wrong_user`. An expired row → `expired`. Single-use is the atomic
163
+ * `UPDATE … SET used_at = now() WHERE id = ? AND used_at IS NULL RETURNING`:
164
+ * the FIRST redeem wins and every later one sees zero affected rows → `used`
165
+ * (this also closes the read-then-write race two concurrent `/verify`s create).
166
+ */
167
+ export async function redeemLinkCode(opts: {
168
+ db: Database;
169
+ connectorId: string;
170
+ platformUserId: string;
171
+ code: string;
172
+ }): Promise<RedeemLinkCodeResult> {
173
+ const { db, connectorId, platformUserId } = opts;
174
+ const code = opts.code.trim();
175
+ if (code.length === 0) return { ok: false, reason: "invalid" };
176
+
177
+ const codeHash = hashLinkCode(code);
178
+
179
+ const [row] = await db
180
+ .select()
181
+ .from(connectorLinkCodes)
182
+ .where(
183
+ and(
184
+ eq(connectorLinkCodes.connectorId, connectorId),
185
+ eq(connectorLinkCodes.codeHash, codeHash),
186
+ ),
187
+ )
188
+ .limit(1);
189
+
190
+ if (!row) return { ok: false, reason: "invalid" };
191
+
192
+ // Identity binding — constant-time so a redeem can't time-probe which codes
193
+ // belong to which account. A mismatch is rejected WITHOUT marking the code
194
+ // used, so the rightful owner can still redeem it.
195
+ if (!safeEqual(row.platformUserId, platformUserId)) {
196
+ return { ok: false, reason: "wrong_user" };
197
+ }
198
+
199
+ if (row.usedAt !== null) return { ok: false, reason: "used" };
200
+ if (row.expiresAt.getTime() <= Date.now()) {
201
+ return { ok: false, reason: "expired" };
202
+ }
203
+
204
+ // Atomic single-use claim: only the redeem that flips `used_at` from NULL
205
+ // wins. A concurrent redeem (or a replay that slipped past the read above)
206
+ // sees zero rows returned.
207
+ const claimed = await db
208
+ .update(connectorLinkCodes)
209
+ .set({ usedAt: sql`now()`, updatedAt: sql`now()` })
210
+ .where(
211
+ and(eq(connectorLinkCodes.id, row.id), isNull(connectorLinkCodes.usedAt)),
212
+ )
213
+ .returning({ id: connectorLinkCodes.id });
214
+
215
+ if (claimed.length === 0) return { ok: false, reason: "used" };
216
+
217
+ return { ok: true, email: row.targetEmail };
218
+ }
@@ -0,0 +1,114 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { safeEqual } from "../webhook-sources/verify.js";
3
+
4
+ /**
5
+ * Engine-owned, GENERIC signed connector state — the CSRF/binding token carried
6
+ * on a connector OAuth `state` query param. The connector OAuth callback lands
7
+ * UNAUTHENTICATED (it is a public redirect target), so every connect/authorize
8
+ * URL must carry a server-minted, server-verified `state`:
9
+ *
10
+ * - `purpose: "install"` — CSRF only (a one-click bot/app install). No contact
11
+ * is bound; the callback just proves the redirect was initiated by us.
12
+ * - `purpose: "member_link"` — binds the EXACT contact/email the per-member
13
+ * link was issued for, so the callback attaches the platform identity to THAT
14
+ * contact (never to whatever email the platform happens to report — the
15
+ * grafting/account-takeover vector).
16
+ *
17
+ * The token is `base64url(JSON(payload)).base64url(HMAC-SHA256(payloadB64))`,
18
+ * signed with the engine's `BETTER_AUTH_SECRET`. The same hardened constant-time
19
+ * compare the connector ingress uses ({@link safeEqual}) guards verification.
20
+ *
21
+ * REPLAY: the token is single-use WHEN a nonce store is available — the OAuth
22
+ * callback burns the per-mint `nonce` on first use (a `SET … NX` in Redis), so a
23
+ * captured callback URL cannot be replayed. Without Redis (self-host without it,
24
+ * tests) it degrades to TTL-bounded validity: the signature + `exp` still gate
25
+ * it, but the same token works until expiry. The mint TTL is the replay window.
26
+ */
27
+ export interface ConnectorStateIntent {
28
+ purpose: "install" | "member_link";
29
+ connectorId: string;
30
+ /** Member-link only — the bound contact id (authoritative resolution key). */
31
+ contactId?: string;
32
+ /** Member-link only — the bound contact email (authoritative resolution key). */
33
+ email?: string;
34
+ /**
35
+ * High-entropy per-mint value so two states are never byte-identical AND so
36
+ * the callback can burn it for single-use replay protection (see header).
37
+ */
38
+ nonce: string;
39
+ }
40
+
41
+ interface SignedStatePayload extends ConnectorStateIntent {
42
+ /** Absolute expiry, seconds since the unix epoch. */
43
+ exp: number;
44
+ }
45
+
46
+ function base64url(input: Buffer | string): string {
47
+ return Buffer.from(input).toString("base64url");
48
+ }
49
+
50
+ function sign(payloadB64: string, secret: string): string {
51
+ return createHmac("sha256", secret).update(payloadB64).digest("base64url");
52
+ }
53
+
54
+ /**
55
+ * Mint a signed connector-state token from an intent. `Date.now()` is fine here
56
+ * — this is engine RUNTIME code (route handlers), not a journey workflow script.
57
+ */
58
+ export function signConnectorState(
59
+ intent: ConnectorStateIntent,
60
+ secret: string,
61
+ ttlSeconds: number,
62
+ ): string {
63
+ const payload: SignedStatePayload = {
64
+ ...intent,
65
+ exp: Math.floor(Date.now() / 1000) + ttlSeconds,
66
+ };
67
+ const payloadB64 = base64url(JSON.stringify(payload));
68
+ const sigB64 = sign(payloadB64, secret);
69
+ return `${payloadB64}.${sigB64}`;
70
+ }
71
+
72
+ /**
73
+ * Verify a signed connector-state token. Recomputes the HMAC, constant-time
74
+ * compares it, then enforces expiry. Returns `{ valid: false, reason }` on ANY
75
+ * malformed/bad/expired input — NEVER throws.
76
+ */
77
+ export function verifyConnectorState(
78
+ token: string,
79
+ secret: string,
80
+ ): { valid: boolean; intent?: ConnectorStateIntent; reason?: string } {
81
+ if (typeof token !== "string" || token.length === 0) {
82
+ return { valid: false, reason: "missing_token" };
83
+ }
84
+ const dot = token.indexOf(".");
85
+ if (dot <= 0 || dot === token.length - 1) {
86
+ return { valid: false, reason: "malformed_token" };
87
+ }
88
+ const payloadB64 = token.slice(0, dot);
89
+ const sigB64 = token.slice(dot + 1);
90
+
91
+ const expectedSig = sign(payloadB64, secret);
92
+ if (!safeEqual(sigB64, expectedSig)) {
93
+ return { valid: false, reason: "bad_signature" };
94
+ }
95
+
96
+ let payload: SignedStatePayload;
97
+ try {
98
+ payload = JSON.parse(
99
+ Buffer.from(payloadB64, "base64url").toString("utf8"),
100
+ ) as SignedStatePayload;
101
+ } catch {
102
+ return { valid: false, reason: "malformed_payload" };
103
+ }
104
+
105
+ if (typeof payload.exp !== "number") {
106
+ return { valid: false, reason: "malformed_payload" };
107
+ }
108
+ if (payload.exp <= Math.floor(Date.now() / 1000)) {
109
+ return { valid: false, reason: "expired" };
110
+ }
111
+
112
+ const { exp: _exp, ...intent } = payload;
113
+ return { valid: true, intent };
114
+ }