@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.
@@ -0,0 +1,107 @@
1
+ import type { AnalyticsProvider } from "@hogsend/core";
2
+ import type { Database } from "@hogsend/db";
3
+ import {
4
+ logResidualTwins,
5
+ mergeAnalyticsIdentities,
6
+ } from "./analytics-identity.js";
7
+ import { resolveOrCreateContact } from "./contacts.js";
8
+ import type { Logger } from "./logger.js";
9
+
10
+ /**
11
+ * Args for {@link IdentityService.linkContact} — the same identity-attach inputs
12
+ * `resolveOrCreateContact` accepts (at least one of `userId`/`email`/
13
+ * `anonymousId`/`discordId` is required by the resolver), minus the `db` (the
14
+ * service closes over the container's db).
15
+ */
16
+ export interface LinkContactArgs {
17
+ userId?: string;
18
+ email?: string;
19
+ anonymousId?: string;
20
+ discordId?: string;
21
+ contactProperties?: Record<string, unknown>;
22
+ }
23
+
24
+ /**
25
+ * The container-held identity helper (`client.identity`). It exists so any
26
+ * identity-attach OUTSIDE the `/v1/events` ingest path — most notably Discord
27
+ * `/link` (§7), but also any consumer wiring — folds two keys into one analytics
28
+ * person through the SAME engine emission used by `ingestEvent` (§5.3), rather
29
+ * than each consumer hand-rolling its own `resolveOrCreateContact` +
30
+ * `mergeIdentities` plumbing (the bespoke path the spec calls out as the bug).
31
+ */
32
+ export interface IdentityService {
33
+ /**
34
+ * Resolve / merge a contact AND propagate the analytics merge in one call.
35
+ *
36
+ * Wraps `resolveOrCreateContact` (the resolver stays analytics-free — it takes
37
+ * only `db`) then, on a collide-MERGE or canonical-key flip that absorbed an
38
+ * anonymous/uuid key, fans out the provider-neutral `mergeIdentities` primitive
39
+ * via {@link mergeAnalyticsIdentities} with `reason: "discord_link"`. MF-2:
40
+ * `mergedKeys` already excludes identified `external_id`s (the resolver split
41
+ * them out) — only the safe anon/uuid keys are aliased; the excluded
42
+ * identified twins surface as `identity.merge.residual_twin` for observability.
43
+ *
44
+ * The SURVIVOR RULE makes `resolvedKey` the survivor (`distinctId`) and each
45
+ * loser its absorbed `alias` — e.g. on a Discord `/link` that merges the
46
+ * discord-keyed contact into the email contact, `distinctId = resolvedKey`
47
+ * (survivor, email/external) and `alias = <discord-contact uuid>` (the
48
+ * loser's anon/uuid key the Discord-platform events were captured under).
49
+ *
50
+ * Best-effort and analytics-non-load-bearing: the merge emission never throws
51
+ * (the helper swallows provider errors), so a missing/incapable provider
52
+ * no-ops cleanly — the contact resolve still happened and is returned.
53
+ */
54
+ linkContact(args: LinkContactArgs): ReturnType<typeof resolveOrCreateContact>;
55
+ }
56
+
57
+ /**
58
+ * Build the {@link IdentityService} bound to a container's db + active analytics
59
+ * provider. `analytics` is undefined when nothing is configured (the merge
60
+ * emission no-ops); the resolver itself is unaffected.
61
+ */
62
+ export function createIdentityService(deps: {
63
+ db: Database;
64
+ analytics?: AnalyticsProvider;
65
+ logger?: Logger;
66
+ }): IdentityService {
67
+ const { db, analytics, logger } = deps;
68
+
69
+ return {
70
+ async linkContact(args) {
71
+ const result = await resolveOrCreateContact({ db, ...args });
72
+
73
+ const {
74
+ id: contactId,
75
+ resolvedKey,
76
+ mergedKeys,
77
+ mergedIdentifiedKeys,
78
+ } = result;
79
+
80
+ // §5.3 emission point 1, reused (§7): fire the analytics merge ONLY when
81
+ // the resolver actually folded keys this call. MF-2: `mergedKeys` carries
82
+ // the safe anon/uuid losers (the discord-contact uuid on a `/link` merge);
83
+ // identified `external_id`s are excluded by the resolver and surfaced as
84
+ // residual twins below — never aliased (the merge PostHog refuses, R2/R4).
85
+ if (mergedKeys?.length) {
86
+ mergeAnalyticsIdentities({
87
+ analytics,
88
+ survivorKey: resolvedKey,
89
+ loserKeys: mergedKeys,
90
+ reason: "discord_link",
91
+ contactId,
92
+ logger,
93
+ });
94
+ }
95
+ if (mergedIdentifiedKeys?.length) {
96
+ logResidualTwins({
97
+ survivorKey: resolvedKey,
98
+ identifiedLoserKeys: mergedIdentifiedKeys,
99
+ contactId,
100
+ logger,
101
+ });
102
+ }
103
+
104
+ return result;
105
+ },
106
+ };
107
+ }
@@ -8,8 +8,12 @@ import {
8
8
  /**
9
9
  * Short-lived identity token appended to tracked-link redirects as `hs_t`
10
10
  * (opt-in via TRACKING_IDENTITY_TOKEN). The landing site exchanges it at
11
- * `POST /v1/t/identify` for the distinct id and calls `posthog.identify`
12
- * stitching the email click to the web session.
11
+ * `POST /v1/t/identify`, where the engine fires a SERVER-SIDE `alias` folding
12
+ * the caller's own anon session into the token's canonical id — stitching the
13
+ * click to the web session. Minted for EMAIL links by default; non-email
14
+ * (Discord/referral) links carry a token only when explicitly stitch-bearing
15
+ * (`tracked_links.distinct_id` set) — referral links are token-less by default
16
+ * (MF-4 anti-hijack).
13
17
  *
14
18
  * ENCRYPTED (AES-256-GCM keyed off BETTER_AUTH_SECRET), not merely signed:
15
19
  * the distinct id can fall back to an email address, and a signed-but-
@@ -18,11 +22,38 @@ import {
18
22
  * auth tag also covers integrity, so tampering fails decryption.
19
23
  */
20
24
 
25
+ /**
26
+ * The only merge mode a token may authorize: fold the CALLER's own anonymous
27
+ * session INTO the token's canonical `distinctId`. There is deliberately no
28
+ * "become the subject" / overwrite mode — that is the anti-hijack invariant.
29
+ */
30
+ export type IdentityTokenScope = "anon-absorb";
31
+
21
32
  export interface IdentityTokenPayload {
22
- /** The distinct id the landing site should identify as. */
33
+ /**
34
+ * The canonical contact key the landing site should fold INTO — the ONLY
35
+ * ever-identified id. NEVER a per-link or anonymous id.
36
+ */
23
37
  distinctId: string;
24
- emailSendId: string;
38
+ /**
39
+ * Where the token was minted: `"email:<sendId>"` | `"link:<linkId>"`.
40
+ * Referral links are excluded by default (they carry no identity token).
41
+ */
42
+ src: string;
43
+ /**
44
+ * The authorized merge mode. Only `"anon-absorb"` is ever minted. OPTIONAL on
45
+ * the wire for the rolling-deploy window (MF-7): a token minted by the still-old
46
+ * click route carries no `scope`, so `validateIdentityToken` treats a MISSING
47
+ * scope as `"anon-absorb"` (allow) and rejects only a PRESENT-and-wrong value.
48
+ */
49
+ scope?: IdentityTokenScope;
25
50
  exp: number;
51
+ /**
52
+ * @deprecated Alias of `src` for ONE minor (mirrors the `resendId` → `messageId`
53
+ * deprecation window). Old tokens carry only `emailSendId`; new email tokens
54
+ * carry both. Reads should prefer `src`.
55
+ */
56
+ emailSendId?: string;
26
57
  }
27
58
 
28
59
  export class InvalidIdentityTokenError extends Error {
@@ -43,11 +74,27 @@ function deriveKey(secret: string): Buffer {
43
74
  export function generateIdentityToken(opts: {
44
75
  secret: string;
45
76
  distinctId: string;
46
- emailSendId: string;
77
+ /**
78
+ * Mint provenance: `"email:<sendId>"` | `"link:<linkId>"`. When omitted, falls
79
+ * back to `email:<emailSendId>` for the legacy email-link caller.
80
+ */
81
+ src?: string;
82
+ /** Defaults to `"anon-absorb"` — the only mode a token may authorize. */
83
+ scope?: IdentityTokenScope;
84
+ /**
85
+ * @deprecated Pass `src` instead. Kept for the one-minor deprecation window so
86
+ * existing email-link callers compile unchanged; mirrored into the payload's
87
+ * deprecated `emailSendId` field and used to synthesize `src` when `src` is
88
+ * absent.
89
+ */
90
+ emailSendId?: string;
47
91
  expiresInSeconds?: number;
48
92
  }): string {
93
+ const src = opts.src ?? (opts.emailSendId ? `email:${opts.emailSendId}` : "");
49
94
  const payload: IdentityTokenPayload = {
50
95
  distinctId: opts.distinctId,
96
+ src,
97
+ scope: opts.scope ?? "anon-absorb",
51
98
  emailSendId: opts.emailSendId,
52
99
  exp:
53
100
  Math.floor(Date.now() / 1000) +
@@ -108,5 +155,18 @@ export function validateIdentityToken(opts: {
108
155
  if (payload.exp < Math.floor(Date.now() / 1000)) {
109
156
  throw new InvalidIdentityTokenError("Token expired");
110
157
  }
158
+ // MF-7 — missing-scope-ALLOW. The API and worker deploy independently from
159
+ // the same image, so a token minted by the still-old click route carries no
160
+ // `scope`. Treat a MISSING scope as the only legal mode (`"anon-absorb"`);
161
+ // reject ONLY a present-and-wrong value. Old tokens (no `scope`, no `src`)
162
+ // still validate — this check never widened the required-shape gate above.
163
+ if (payload.scope !== undefined && payload.scope !== "anon-absorb") {
164
+ throw new InvalidIdentityTokenError("Unsupported token scope");
165
+ }
166
+ // Backfill `src` from the deprecated `emailSendId` for old tokens, so the one
167
+ // response schema (`{ distinctId, src, emailSendId? }`) is always populated.
168
+ if (typeof payload.src !== "string" || payload.src.length === 0) {
169
+ payload.src = payload.emailSendId ? `email:${payload.emailSendId}` : "";
170
+ }
111
171
  return payload;
112
172
  }
@@ -1,9 +1,14 @@
1
1
  import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
2
+ import type { AnalyticsProvider } from "@hogsend/core";
2
3
  import { evaluatePropertyConditions } from "@hogsend/core";
3
4
  import type { JourneyRegistry } from "@hogsend/core/registry";
4
5
  import { type Database, journeyStates, userEvents } from "@hogsend/db";
5
6
  import { and, eq, inArray, isNull } from "drizzle-orm";
6
7
  import { checkBucketMembership } from "../buckets/check-membership.js";
8
+ import {
9
+ logResidualTwins,
10
+ mergeAnalyticsIdentities,
11
+ } from "./analytics-identity.js";
7
12
  import { resolveOrCreateContact } from "./contacts.js";
8
13
  import type { Logger } from "./logger.js";
9
14
 
@@ -14,6 +19,11 @@ export interface IngestEvent {
14
19
  userEmail?: string;
15
20
  /** D1: future anonymous→identified path. Threaded into the resolver. */
16
21
  anonymousId?: string;
22
+ /**
23
+ * Discord user id (snowflake). Resolves a `discord`-keyed contact (a later
24
+ * per-member link merges it into the email contact).
25
+ */
26
+ discordId?: string;
17
27
  /** D2: → `user_events` + Hatchet `trigger.where`/`exitOn` ONLY. */
18
28
  eventProperties: Record<string, unknown>;
19
29
  /** D2: → `contacts.properties` merge ONLY. */
@@ -53,8 +63,17 @@ export async function ingestEvent(opts: {
53
63
  hatchet: HatchetClient;
54
64
  logger: Logger;
55
65
  event: IngestEvent;
66
+ /**
67
+ * The active analytics provider (`c.get("container").analytics`). When the
68
+ * identity resolve folds two keys into one (collide-MERGE or canonical-key
69
+ * flip), the engine fires the provider-neutral `mergeIdentities` primitive so
70
+ * the analytics person store stitches the same way the contact store did
71
+ * (§5.3). Optional: absent ⇒ DB-only resolve (no stitch), exactly as before; a
72
+ * provider without `identityMerge` no-ops cleanly.
73
+ */
74
+ analytics?: AnalyticsProvider;
56
75
  }): Promise<IngestResult> {
57
- const { db, registry, hatchet, logger, event } = opts;
76
+ const { db, registry, hatchet, logger, event, analytics } = opts;
58
77
 
59
78
  // (1) Resolve identity FIRST (awaited — no longer fire-and-forget). The
60
79
  // contact-referencing tables join on a NOT NULL text key, so an email-only /
@@ -63,11 +82,18 @@ export async function ingestEvent(opts: {
63
82
  // `contacts.properties` (D2 split) and returns BOTH the canonical contact id
64
83
  // AND its resolved string key (external_id ?? anonymous_id ?? contact.id —
65
84
  // risk 1/6), so no second read-back of the contact row is needed.
66
- const { resolvedKey } = await resolveOrCreateContact({
85
+ const {
86
+ id: contactId,
87
+ resolvedKey,
88
+ mergedKeys,
89
+ mergedIdentifiedKeys,
90
+ merged,
91
+ } = await resolveOrCreateContact({
67
92
  db,
68
93
  userId: event.userId,
69
94
  email: event.userEmail || undefined,
70
95
  anonymousId: event.anonymousId,
96
+ discordId: event.discordId,
71
97
  contactProperties: event.contactProperties,
72
98
  });
73
99
 
@@ -106,6 +132,36 @@ export async function ingestEvent(opts: {
106
132
  });
107
133
  }
108
134
 
135
+ // (2b) §5.3 — fire the provider-neutral identity merge at the two resolver
136
+ // outcomes where two keys fold into one (collide-MERGE or canonical-key flip).
137
+ // Placed INSIDE the idempotency-guarded block (after a FRESH insert; the
138
+ // duplicate path returned early above) so a Hatchet/client retry with the same
139
+ // idempotencyKey does NOT re-fire `alias` — honoring the "only at the moment
140
+ // two keys first become one" contract (PostHog `alias` is harmless on replay
141
+ // but firing per-retry adds queue noise). MF-2: `mergedKeys` already excludes
142
+ // identified `external_id`s (the resolver split them out); fire only the safe
143
+ // anon/uuid keys, and surface the excluded identified twins for observability.
144
+ if (mergedKeys?.length || mergedIdentifiedKeys?.length) {
145
+ if (mergedKeys?.length) {
146
+ mergeAnalyticsIdentities({
147
+ analytics,
148
+ survivorKey: resolvedKey,
149
+ loserKeys: mergedKeys,
150
+ reason: merged ? "collide_merge" : "key_flip",
151
+ contactId,
152
+ logger,
153
+ });
154
+ }
155
+ if (mergedIdentifiedKeys?.length) {
156
+ logResidualTwins({
157
+ survivorKey: resolvedKey,
158
+ identifiedLoserKeys: mergedIdentifiedKeys,
159
+ contactId,
160
+ logger,
161
+ });
162
+ }
163
+ }
164
+
109
165
  // (3) Build the JSON-serializable subset of eventProperties for the Hatchet
110
166
  // push payload (scalars only — the SDK serializes the envelope).
111
167
  const serializableProperties = Object.fromEntries(
@@ -90,6 +90,23 @@ export interface OutboundPayloads {
90
90
  "email.delivered": EmailEventPayload;
91
91
  "email.opened": EmailEventPayload;
92
92
  "email.clicked": EmailEventPayload & { linkUrl?: string; linkId?: string };
93
+ /**
94
+ * A NON-email tracked link was clicked (Discord/referral/ad-hoc
95
+ * `createTrackedLink`). The deliberate counterpart to `email.clicked` — a
96
+ * non-email click has no `email_sends` row, so it carries `emailSendId: null`
97
+ * and `messageId: null` and never masquerades as an email click
98
+ * (MF-missing #3). `userId` is the link's stitch subject (`distinct_id`) when
99
+ * the link is identity-bearing, else null for a broadcast link.
100
+ */
101
+ "link.clicked": {
102
+ linkId: string;
103
+ source: string | null;
104
+ userId: string | null;
105
+ emailSendId: null;
106
+ messageId: null;
107
+ linkUrl: string;
108
+ at: string;
109
+ };
93
110
  /**
94
111
  * A SEMANTIC link answered — the in-email action event (consumer-named, e.g.
95
112
  * "nps.submitted"). Emitted at most once per (send, event name): first
@@ -54,6 +54,17 @@ export interface DerivedCredentialPayload {
54
54
  projectApiKey?: string;
55
55
  projectId?: string;
56
56
  privateHost?: string;
57
+ // --- Discord (kind="derived", providerId="discord") ---
58
+ // Server-derived during `hogsend connect discord`: the app id + public key
59
+ // are read by the admin connect-info / interactions routes; the guild id is
60
+ // captured from the bot-install OAuth grant; the bot token + client secret
61
+ // (when stored here rather than env) feed the gateway worker / code exchange.
62
+ // All optional — the store is provider-neutral and additive.
63
+ discordAppId?: string;
64
+ discordPublicKey?: string;
65
+ discordClientSecret?: string;
66
+ discordBotToken?: string;
67
+ discordGuildId?: string;
57
68
  }
58
69
 
59
70
  /** Row metadata — everything EXCEPT token material. Safe to surface. */
@@ -90,6 +90,15 @@ export async function confirmSemanticClick(
90
90
  if (!link?.event) {
91
91
  return { status: "skipped", reason: "not_semantic" };
92
92
  }
93
+ // The confirm path is EMAIL-semantic end to end (it claims a send's answer
94
+ // slot keyed on `emailSendId` and emits `email.action`). The click route only
95
+ // enqueues this task for links with a non-null `emailSendId`, but `emailSendId`
96
+ // is nullable since the identity-stitching minor — guard defensively and
97
+ // narrow the type for the rest of the function.
98
+ if (!link.emailSendId) {
99
+ return { status: "skipped", reason: "non_email_link" };
100
+ }
101
+ const emailSendId = link.emailSendId;
93
102
  const semanticEvent = link.event;
94
103
 
95
104
  // (1) Let the burst window close before judging the click.
@@ -109,7 +118,7 @@ export async function confirmSemanticClick(
109
118
  .innerJoin(trackedLinks, eq(linkClicks.trackedLinkId, trackedLinks.id))
110
119
  .where(
111
120
  and(
112
- eq(trackedLinks.emailSendId, link.emailSendId),
121
+ eq(trackedLinks.emailSendId, emailSendId),
113
122
  gte(linkClicks.clickedAt, windowStart),
114
123
  lte(linkClicks.clickedAt, windowEnd),
115
124
  ),
@@ -117,7 +126,7 @@ export async function confirmSemanticClick(
117
126
  const distinctLinks = burst[0]?.n ?? 0;
118
127
  if (distinctLinks >= SEMANTIC_BURST_DISTINCT_LINKS) {
119
128
  logger.warn("Semantic answer suppressed: scanner-like click burst", {
120
- emailSendId: link.emailSendId,
129
+ emailSendId,
121
130
  linkId: link.id,
122
131
  event: semanticEvent,
123
132
  distinctLinks,
@@ -125,21 +134,21 @@ export async function confirmSemanticClick(
125
134
  return { status: "suppressed", distinctLinks };
126
135
  }
127
136
 
128
- const ctx = await resolveEmailSendContext(db, link.emailSendId);
137
+ const ctx = await resolveEmailSendContext(db, emailSendId);
129
138
  if (!ctx) {
130
139
  return { status: "skipped", reason: "no_send_context" };
131
140
  }
132
141
 
133
142
  // (3) Claim the answer slot. Duplicate key → stored=false BEFORE the Hatchet
134
143
  // push, so journeys/destinations see at most one answer per (send, event).
135
- const semKey = `sem:${link.emailSendId}:${semanticEvent}`;
144
+ const semKey = `sem:${emailSendId}:${semanticEvent}`;
136
145
  const result = await pushTrackingEvent({
137
146
  db,
138
147
  hatchet,
139
148
  registry,
140
149
  logger,
141
150
  event: semanticEvent,
142
- emailSendId: link.emailSendId,
151
+ emailSendId,
143
152
  properties: {
144
153
  ...(link.eventProperties ?? {}),
145
154
  linkId: link.id,
@@ -180,7 +189,7 @@ export async function confirmSemanticClick(
180
189
  payload: {
181
190
  event: semanticEvent,
182
191
  properties: link.eventProperties ?? null,
183
- emailSendId: link.emailSendId,
192
+ emailSendId,
184
193
  templateKey: ctx.templateKey ?? null,
185
194
  userId: ctx.userId ?? null,
186
195
  to: ctx.to ?? ctx.userEmail ?? "",
@@ -15,8 +15,12 @@ interface EmailSendContext {
15
15
 
16
16
  export async function resolveEmailSendContext(
17
17
  db: Database,
18
- emailSendId: string,
18
+ emailSendId: string | null,
19
19
  ): Promise<EmailSendContext | null> {
20
+ // A non-email tracked link (Discord/referral/ad-hoc `createTrackedLink`) has
21
+ // a NULL `email_send_id` — there is no send row to resolve, so short-circuit
22
+ // to null rather than issue a `WHERE id = NULL` query that matches nothing.
23
+ if (!emailSendId) return null;
20
24
  const rows = await db
21
25
  .select({
22
26
  toEmail: emailSends.toEmail,
@@ -240,3 +240,40 @@ export async function prepareTrackedHtml(opts: {
240
240
  });
241
241
  return result;
242
242
  }
243
+
244
+ /**
245
+ * The mint surface for a NON-email tracked link (Discord, referral, ad-hoc).
246
+ * Inserts a `tracked_links` row with a NULL `emailSendId` and returns the
247
+ * `/v1/t/c/:id` redirect URL to use in place of the raw destination.
248
+ *
249
+ * This is the SINGLE chokepoint enforcing "broadcast links carry no subject":
250
+ * a link only becomes identity-bearing when the caller EXPLICITLY passes
251
+ * `distinctId` (the canonical contact key the click should stitch into). Per
252
+ * MF-4, the referral path does NOT pass `distinctId` by default (referral
253
+ * pages are shareable → broadcast), and the Discord destination passes
254
+ * `distinctId: undefined`. The `hs_t` mint at click time is still gated by
255
+ * `TRACKING_IDENTITY_TOKEN` (default false); a row with a NULL `distinctId`
256
+ * never mints a token regardless.
257
+ */
258
+ export async function createTrackedLink(opts: {
259
+ db: Database;
260
+ url: string;
261
+ /**
262
+ * The canonical contact key a click should fold the visitor's anon session
263
+ * into. OMIT for a broadcast link (the safe default) — only an explicit,
264
+ * single-subject, non-shareable link should pass this.
265
+ */
266
+ distinctId?: string;
267
+ source: "discord" | "referral" | "link";
268
+ baseUrl: string;
269
+ }): Promise<string> {
270
+ const id = randomUUID();
271
+ await opts.db.insert(trackedLinks).values({
272
+ id,
273
+ emailSendId: null,
274
+ distinctId: opts.distinctId ?? null,
275
+ source: opts.source,
276
+ originalUrl: opts.url,
277
+ });
278
+ return `${opts.baseUrl}/v1/t/c/${id}`;
279
+ }
@@ -25,9 +25,14 @@ import { Webhook } from "svix";
25
25
  */
26
26
 
27
27
  /**
28
- * The 14-event catalog — the SINGLE source of truth (schema, routes, client,
28
+ * The 15-event catalog — the SINGLE source of truth (schema, routes, client,
29
29
  * CLI all derive from this). The `webhook.test` sentinel is intentionally NOT a
30
30
  * member (it is delivered out-of-band regardless of an endpoint's `eventTypes`).
31
+ *
32
+ * `link.clicked` is the NON-email click event: a click on a tracked link that
33
+ * has no email send (Discord/referral/ad-hoc `createTrackedLink`). It is the
34
+ * deliberate counterpart to `email.clicked` so a non-email click never fires a
35
+ * malformed `email.clicked` (MF-missing #3).
31
36
  */
32
37
  export const WEBHOOK_EVENT_TYPES = [
33
38
  "contact.created",
@@ -44,6 +49,7 @@ export const WEBHOOK_EVENT_TYPES = [
44
49
  "journey.completed",
45
50
  "bucket.entered",
46
51
  "bucket.left",
52
+ "link.clicked",
47
53
  ] as const;
48
54
 
49
55
  export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number];