@hogsend/engine 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -100,6 +100,7 @@ export function contactSearchFilter(search: string) {
100
100
  ilike(contacts.email, `%${search}%`),
101
101
  ilike(contacts.externalId, `%${search}%`),
102
102
  ilike(contacts.anonymousId, `%${search}%`),
103
+ ilike(contacts.discordId, `%${search}%`),
103
104
  );
104
105
  }
105
106
 
@@ -115,7 +116,7 @@ export function normalizeEmail(raw: string): string {
115
116
  // Identity resolution
116
117
  // ---------------------------------------------------------------------------
117
118
 
118
- type Kind = "external" | "email" | "anonymous";
119
+ type Kind = "external" | "email" | "anonymous" | "discord";
119
120
 
120
121
  interface ResolveKey {
121
122
  kind: Kind;
@@ -137,7 +138,9 @@ async function findByKey(tx: Tx, key: ResolveKey): Promise<ContactRow | null> {
137
138
  ? contacts.externalId
138
139
  : key.kind === "email"
139
140
  ? contacts.email
140
- : contacts.anonymousId;
141
+ : key.kind === "anonymous"
142
+ ? contacts.anonymousId
143
+ : contacts.discordId;
141
144
 
142
145
  const direct = await tx
143
146
  .select()
@@ -187,6 +190,18 @@ async function findByKey(tx: Tx, key: ResolveKey): Promise<ContactRow | null> {
187
190
  return null;
188
191
  }
189
192
 
193
+ /**
194
+ * Top-level property keys whose object value is DEEP-merged (one level) rather
195
+ * than wholly replaced. The §2.1 shallow `||` contract clobbers a top-level key
196
+ * outright, so a nested metadata object (e.g. the Discord connector's
197
+ * `properties.discord`) would lose every field the current event doesn't carry
198
+ * (a reaction knows `last_seen` but not `username`, so it would erase a
199
+ * previously-captured `username`). Listing the key here makes ONLY that key
200
+ * additive — siblings stay strictly shallow, preserving the documented contract
201
+ * for everything else. NON-KEY metadata only; never an identity-resolution key.
202
+ */
203
+ const DEEP_MERGE_KEYS = ["discord"] as const;
204
+
190
205
  /**
191
206
  * Merge `patch` onto the existing jsonb properties (§2.1 contract): additive
192
207
  * `COALESCE(existing,'{}') || patch` where the patch wins on key conflict AND an
@@ -197,9 +212,56 @@ async function findByKey(tx: Tx, key: ResolveKey): Promise<ContactRow | null> {
197
212
  * Caveat: `jsonb_strip_nulls` also strips any PRE-EXISTING null-valued keys on
198
213
  * the contact, which is the intended "null === unset" model (the condition
199
214
  * engine already treats JSON null and absent identically).
215
+ *
216
+ * EXCEPTION — keys in {@link DEEP_MERGE_KEYS} that carry an object value are
217
+ * merged ONE level deep: `existing.discord || patch.discord` instead of the
218
+ * top-level `||` replacing `discord` wholesale. Postgres has no recursive `||`,
219
+ * so we build the deep-merged sub-object explicitly and overlay it last. A
220
+ * non-object value for such a key (or an absent one) falls through to the normal
221
+ * shallow merge untouched.
200
222
  */
201
223
  function mergePropertiesSql(patch: Record<string, unknown>) {
202
- return sql`jsonb_strip_nulls(COALESCE(${contacts.properties}, '{}'::jsonb) || ${JSON.stringify(patch)}::jsonb)`;
224
+ let merged = sql`COALESCE(${contacts.properties}, '{}'::jsonb) || ${JSON.stringify(patch)}::jsonb`;
225
+ for (const key of DEEP_MERGE_KEYS) {
226
+ const sub = patch[key];
227
+ if (sub && typeof sub === "object" && !Array.isArray(sub)) {
228
+ // existing[key] (already an object or absent) || patch[key] — the prior
229
+ // sub-object's fields survive any field the current patch omits.
230
+ // `${key}` is cast to ::text: jsonb_build_object is VARIADIC "any" and `->`
231
+ // is overloaded (text key vs int index), so an untyped bound parameter
232
+ // can't have its type inferred ("could not determine data type of $n").
233
+ merged = sql`${merged} || jsonb_build_object(${key}::text, COALESCE(${contacts.properties} -> ${key}::text, '{}'::jsonb) || ${JSON.stringify(sub)}::jsonb)`;
234
+ }
235
+ }
236
+ return sql`jsonb_strip_nulls(${merged})`;
237
+ }
238
+
239
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
240
+ return Boolean(v) && typeof v === "object" && !Array.isArray(v);
241
+ }
242
+
243
+ /**
244
+ * Spread-merge one `layer` onto the accumulated `acc` (incoming wins per key),
245
+ * the in-memory analogue of {@link mergePropertiesSql}'s deep-merge exception
246
+ * for the collide-MERGE fold (which folds properties via JS spread, not SQL).
247
+ * For each {@link DEEP_MERGE_KEYS} key that is an object on BOTH `acc` and the
248
+ * incoming `layer`, the sub-objects are themselves shallow-merged (incoming
249
+ * wins per sub-key) so the layer can't clobber fields the accumulator already
250
+ * holds — must read the PRE-spread `acc` value, hence a fresh result object.
251
+ */
252
+ function foldLayer(
253
+ acc: Record<string, unknown>,
254
+ layer: Record<string, unknown>,
255
+ ): Record<string, unknown> {
256
+ const out: Record<string, unknown> = { ...acc, ...layer };
257
+ for (const key of DEEP_MERGE_KEYS) {
258
+ const a = acc[key];
259
+ const b = layer[key];
260
+ if (isPlainObject(a) && isPlainObject(b)) {
261
+ out[key] = { ...a, ...b };
262
+ }
263
+ }
264
+ return out;
203
265
  }
204
266
 
205
267
  /**
@@ -277,6 +339,7 @@ export async function resolveOrCreateContact(opts: {
277
339
  userId?: string;
278
340
  email?: string;
279
341
  anonymousId?: string;
342
+ discordId?: string;
280
343
  contactProperties?: Record<string, unknown>;
281
344
  }): Promise<{
282
345
  id: string;
@@ -295,15 +358,18 @@ export async function resolveOrCreateContact(opts: {
295
358
  const userId = opts.userId?.trim() || undefined;
296
359
  const email = opts.email ? normalizeEmail(opts.email) : undefined;
297
360
  const anonymousId = opts.anonymousId?.trim() || undefined;
361
+ const discordId = opts.discordId?.trim() || undefined;
298
362
 
299
363
  const keys: ResolveKey[] = [];
300
364
  if (userId) keys.push({ kind: "external", value: userId });
301
365
  if (email) keys.push({ kind: "email", value: email });
302
366
  if (anonymousId) keys.push({ kind: "anonymous", value: anonymousId });
367
+ if (discordId) keys.push({ kind: "discord", value: discordId });
303
368
 
304
369
  if (keys.length === 0) {
305
370
  throw new Error(
306
- "resolveOrCreateContact requires at least one of userId, email, anonymousId",
371
+ "resolveOrCreateContact requires at least one of userId, email, " +
372
+ "anonymousId, discordId",
307
373
  );
308
374
  }
309
375
 
@@ -338,6 +404,7 @@ export async function resolveOrCreateContact(opts: {
338
404
  externalId: userId ?? null,
339
405
  email: email ?? null,
340
406
  anonymousId: anonymousId ?? null,
407
+ discordId: discordId ?? null,
341
408
  // §2.1: explicit null clears a key — never persist a null-valued prop.
342
409
  properties: stripNulls(patch),
343
410
  })
@@ -360,6 +427,7 @@ export async function resolveOrCreateContact(opts: {
360
427
  userId,
361
428
  email,
362
429
  anonymousId,
430
+ discordId,
363
431
  patch,
364
432
  hasPatch,
365
433
  });
@@ -371,6 +439,7 @@ export async function resolveOrCreateContact(opts: {
371
439
  userId,
372
440
  email,
373
441
  anonymousId,
442
+ discordId,
374
443
  patch,
375
444
  hasPatch,
376
445
  });
@@ -382,6 +451,7 @@ interface ResolveCtx {
382
451
  userId?: string;
383
452
  email?: string;
384
453
  anonymousId?: string;
454
+ discordId?: string;
385
455
  patch: Record<string, unknown>;
386
456
  hasPatch: boolean;
387
457
  }
@@ -420,6 +490,14 @@ async function fillInLink(
420
490
  set.email = ctx.email;
421
491
  promoted.push({ kind: "email", value: ctx.email });
422
492
  }
493
+ // discord_id is an attachable resolvable key but NEVER the canonical key
494
+ // (external_id ?? anonymous_id ?? id), so it does NOT touch
495
+ // nextExternalId/nextAnonymousId — gaining it never flips the canonical key,
496
+ // so no own-history re-point follows.
497
+ if (ctx.discordId && !row.discordId) {
498
+ set.discordId = ctx.discordId;
499
+ promoted.push({ kind: "discord", value: ctx.discordId });
500
+ }
423
501
  if (ctx.anonymousId && !row.anonymousId) {
424
502
  set.anonymousId = ctx.anonymousId;
425
503
  nextAnonymousId = ctx.anonymousId;
@@ -518,14 +596,23 @@ async function mergeContacts(
518
596
  }
519
597
 
520
598
  // (vii) FOLD properties: survivor wins over losers; then the call's patch wins
521
- // last. timezone = survivor ?? loser; firstSeenAt = least.
599
+ // last. timezone = survivor ?? loser; firstSeenAt = least. DEEP_MERGE_KEYS
600
+ // (e.g. `discord`) are sub-object-merged at each fold layer (foldLayer) so a
601
+ // loser/survivor/patch that carries only a subset of the nested object's
602
+ // fields doesn't clobber the rest — matching mergePropertiesSql's exception.
522
603
  let foldedProps: Record<string, unknown> = {};
523
604
  for (const loser of losers) {
524
- foldedProps = { ...foldedProps, ...((loser.properties ?? {}) as object) };
605
+ foldedProps = foldLayer(
606
+ foldedProps,
607
+ (loser.properties ?? {}) as Record<string, unknown>,
608
+ );
525
609
  }
526
- foldedProps = { ...foldedProps, ...((survivor.properties ?? {}) as object) };
610
+ foldedProps = foldLayer(
611
+ foldedProps,
612
+ (survivor.properties ?? {}) as Record<string, unknown>,
613
+ );
527
614
  if (ctx.hasPatch) {
528
- foldedProps = { ...foldedProps, ...ctx.patch };
615
+ foldedProps = foldLayer(foldedProps, ctx.patch);
529
616
  }
530
617
  // §2.1: an explicit null in the call's patch clears a key — drop null-valued
531
618
  // keys from the folded result (matching mergePropertiesSql's strip-nulls).
@@ -562,6 +649,16 @@ async function mergeContacts(
562
649
  const next = ctx.anonymousId ?? fromLoser;
563
650
  if (next) survivorSet.anonymousId = next;
564
651
  }
652
+ // discord_id lands on the survivor (from the call or a loser), but it is
653
+ // NEVER the canonical key — so it is intentionally NOT folded into
654
+ // newSurvivorKey below and a discord-only merge does no history re-point. The
655
+ // losers are soft-deleted FIRST (below) so the partial-unique discord_id index
656
+ // is freed before this copy.
657
+ if (!survivor.discordId) {
658
+ const fromLoser = losers.find((l) => l.discordId)?.discordId;
659
+ const next = ctx.discordId ?? fromLoser;
660
+ if (next) survivorSet.discordId = next;
661
+ }
565
662
 
566
663
  // (viii) Soft-delete the losers FIRST — frees their external_id/email/
567
664
  // anonymous_id from the partial-unique indexes (WHERE deleted_at IS NULL) —
@@ -953,6 +1050,20 @@ async function recordMergeAliases(
953
1050
  reason: "merge",
954
1051
  });
955
1052
  }
1053
+ // discord_id is a resolvable key, so a stale loser snowflake must still
1054
+ // resolve to the survivor after the soft-delete takes the loser out of
1055
+ // findByKey's direct lookup. Additive — it never conflicts with the
1056
+ // external/anonymous id-fallback alias below (a discord-only loser produces
1057
+ // BOTH this discord alias AND the id→external alias).
1058
+ if (loser.discordId) {
1059
+ aliasRows.push({
1060
+ contactId: survivorId,
1061
+ aliasKind: "discord",
1062
+ aliasValue: loser.discordId,
1063
+ fromContactId: loser.id,
1064
+ reason: "merge",
1065
+ });
1066
+ }
956
1067
  // When the loser had neither external_id nor anonymous_id, its CANONICAL key
957
1068
  // (`external_id ?? anonymous_id ?? id`) was its row id — and that key has
958
1069
  // circulated (Hatchet payloads, outbound `userId`s, `hs_t` tokens). Alias it
@@ -1002,6 +1113,7 @@ export async function upsertContact(opts: {
1002
1113
  externalId?: string;
1003
1114
  email?: string;
1004
1115
  anonymousId?: string;
1116
+ discordId?: string;
1005
1117
  properties?: Record<string, unknown>;
1006
1118
  }): Promise<{
1007
1119
  id: string;
@@ -1015,6 +1127,7 @@ export async function upsertContact(opts: {
1015
1127
  userId: opts.externalId,
1016
1128
  email: opts.email,
1017
1129
  anonymousId: opts.anonymousId,
1130
+ discordId: opts.discordId,
1018
1131
  contactProperties: opts.properties,
1019
1132
  });
1020
1133
  }