@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/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
|
+
}
|