@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.
- 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 +30 -0
- package/src/routes/admin/analytics.ts +6 -6
- package/src/routes/admin/connectors.ts +466 -0
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/admin/provider-credentials.ts +15 -6
- 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 +30 -10
- package/src/webhook-sources/define-webhook-source.ts +44 -39
- package/src/webhook-sources/verify.ts +4 -1
|
@@ -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
|
+
}
|
package/src/lib/contacts.ts
CHANGED
|
@@ -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
|
-
:
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
605
|
+
foldedProps = foldLayer(
|
|
606
|
+
foldedProps,
|
|
607
|
+
(loser.properties ?? {}) as Record<string, unknown>,
|
|
608
|
+
);
|
|
525
609
|
}
|
|
526
|
-
foldedProps =
|
|
610
|
+
foldedProps = foldLayer(
|
|
611
|
+
foldedProps,
|
|
612
|
+
(survivor.properties ?? {}) as Record<string, unknown>,
|
|
613
|
+
);
|
|
527
614
|
if (ctx.hasPatch) {
|
|
528
|
-
foldedProps =
|
|
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
|
}
|