@hogsend/engine 0.18.0 → 0.20.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,14 +40,14 @@
40
40
  "svix": "^1.95.1",
41
41
  "winston": "^3.19.0",
42
42
  "zod": "^4.4.3",
43
- "@hogsend/core": "^0.18.0",
44
- "@hogsend/db": "^0.18.0",
45
- "@hogsend/email": "^0.18.0",
46
- "@hogsend/plugin-posthog": "^0.18.0",
47
- "@hogsend/plugin-resend": "^0.18.0"
43
+ "@hogsend/core": "^0.20.0",
44
+ "@hogsend/db": "^0.20.0",
45
+ "@hogsend/email": "^0.20.0",
46
+ "@hogsend/plugin-posthog": "^0.20.0",
47
+ "@hogsend/plugin-resend": "^0.20.0"
48
48
  },
49
49
  "optionalDependencies": {
50
- "@hogsend/plugin-postmark": "^0.18.0"
50
+ "@hogsend/plugin-postmark": "^0.20.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^22.15.3",
package/src/container.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
2
- import type { EmailProvider, PostHogService, TimeZone } from "@hogsend/core";
2
+ import type {
3
+ AnalyticsProvider,
4
+ EmailProvider,
5
+ PostHogService,
6
+ TimeZone,
7
+ } from "@hogsend/core";
3
8
  import type { BucketRegistry, JourneyRegistry } from "@hogsend/core/registry";
4
9
  import type { SendWindow } from "@hogsend/core/schedule";
5
10
  import {
@@ -25,6 +30,12 @@ import { env } from "./env.js";
25
30
  import { setClientScheduleDefaults } from "./journeys/client-defaults-singleton.js";
26
31
  import type { DefinedJourney } from "./journeys/define-journey.js";
27
32
  import { buildJourneyRegistry } from "./journeys/registry.js";
33
+ import {
34
+ isAnalyticsProvider,
35
+ wrapLegacyAnalyticsService,
36
+ } from "./lib/analytics-adapter.js";
37
+ import { AnalyticsProviderRegistry } from "./lib/analytics-provider-registry.js";
38
+ import { analyticsProvidersFromEnv } from "./lib/analytics-providers-from-env.js";
28
39
  import { setAnalytics } from "./lib/analytics-singleton.js";
29
40
  import { type Auth, createAuth } from "./lib/auth.js";
30
41
  import {
@@ -41,7 +52,6 @@ import type {
41
52
  import { hatchet } from "./lib/hatchet.js";
42
53
  import { createLogger, type Logger } from "./lib/logger.js";
43
54
  import { createTrackedMailer } from "./lib/mailer.js";
44
- import { getPostHog } from "./lib/posthog.js";
45
55
  import { createRedisSecondaryStorage, getRedis } from "./lib/redis.js";
46
56
  import { sendResetPasswordEmail } from "./lib/reset-email.js";
47
57
  import { seedPostHogDestination } from "./lib/seed-posthog-destination.js";
@@ -92,7 +102,18 @@ export interface HogsendClient {
92
102
  * templates without going through a send. Empty when no templates are wired.
93
103
  */
94
104
  templates: TemplateRegistry;
95
- analytics?: PostHogService;
105
+ /**
106
+ * The container-held registry of analytics providers, keyed by `meta.id` —
107
+ * the analytics sibling of {@link emailProviders}. Built from env presets
108
+ * (`analyticsProvidersFromEnv`) merged consumer-last.
109
+ */
110
+ analyticsProviders: AnalyticsProviderRegistry;
111
+ /**
112
+ * The single resolved ACTIVE analytics provider (identity PULL + person
113
+ * writes + capture). Undefined when nothing is configured — every consumer
114
+ * treats that as a silent no-op.
115
+ */
116
+ analytics?: AnalyticsProvider;
96
117
  registry: JourneyRegistry;
97
118
  /**
98
119
  * The bucket registry (id map + event/property inverted indexes for candidate
@@ -171,25 +192,43 @@ export interface HogsendClientOptions {
171
192
  templates?: TemplateRegistry;
172
193
  };
173
194
  /**
174
- * The PostHog-style analytics service. As of the destinations spine its role
195
+ * The analytics provider(s) provider-neutral since the
196
+ * `AnalyticsProvider` contract (the analytics sibling of `EmailProvider`;
197
+ * PostHog is the reference implementation, not the architecture). Its role
175
198
  * is deliberately NARROW — it is NOT the outbound-catalog firing path (the
176
- * email/contact/journey/bucket lifecycle now fans out durably via
177
- * DESTINATIONS on the webhook spine, keyed by `webhook_endpoints.kind`). It
178
- * remains for exactly two things:
199
+ * email/contact/journey/bucket lifecycle fans out durably via DESTINATIONS
200
+ * on the webhook spine). The ACTIVE provider serves:
179
201
  *
180
202
  * 1. The identity PULL — `getPersonProperties` for per-user timezone
181
203
  * resolution at journey enrollment (`define-journey` / `lib/timezone.ts`).
182
- * This read role is UNCHANGED and load-bearing.
183
- * 2. The opt-in `bucket.syncToPostHog` person-property mirror — `$set`/`$unset`
184
- * of a boolean cohort property on bucket transitions (`bucket-posthog-sync`).
185
- * Off by default; PostHog `$set`/`$unset` identity semantics have no
186
- * vendor-neutral envelope, so this stays a PostHog-direct write.
204
+ * On PostHog this needs `POSTHOG_PERSONAL_API_KEY` (the phc_ project key
205
+ * is write-only by design); reads soft-fail to contact-property
206
+ * fallbacks without it.
207
+ * 2. Person WRITES `setPersonProperties` (the opt-in `bucket.syncToPostHog`
208
+ * mirror, and trait propagation). Rides the capture pipeline; no extra
209
+ * credential.
210
+ *
211
+ * Accepted shapes (mirrors `email`):
212
+ * - a group: `{ provider?, providers?, defaultProvider? }` — register one or
213
+ * many `AnalyticsProvider`s; env presets (`analyticsProvidersFromEnv` —
214
+ * PostHog when `POSTHOG_API_KEY` is set) merge consumer-LAST;
215
+ * `defaultProvider` / env `ANALYTICS_PROVIDER` picks the active id
216
+ * (default `"posthog"`).
217
+ * - a bare `AnalyticsProvider` — registered and made active.
218
+ * - @deprecated a legacy `PostHogService` — wrapped via
219
+ * `wrapLegacyAnalyticsService` and made active.
187
220
  *
188
221
  * Lives at the top level (not under `email`) because the engine itself uses
189
- * it for the PULL. Defaults to {@link getPostHog} (a no-op when
190
- * `POSTHOG_API_KEY` is unset).
222
+ * it for the PULL.
191
223
  */
192
- analytics?: PostHogService;
224
+ analytics?:
225
+ | PostHogService
226
+ | AnalyticsProvider
227
+ | {
228
+ provider?: AnalyticsProvider;
229
+ providers?: AnalyticsProvider[];
230
+ defaultProvider?: string;
231
+ };
193
232
  /**
194
233
  * Code-defined outbound DESTINATIONS (Phase 3). Each is a
195
234
  * `defineDestination()` delivery-time transform keyed by its `meta.id`, which
@@ -462,16 +501,76 @@ export function createHogsendClient(
462
501
  },
463
502
  });
464
503
 
465
- const analytics = opts.analytics ?? getPostHog();
504
+ // Resolve the analytics provider(s) — mirrors the email-provider shape:
505
+ // env presets first, consumer registrations LAST (last-writer-wins), then
506
+ // ONE active provider picked by id. The deprecated bare-PostHogService and
507
+ // bare-AnalyticsProvider forms register-and-activate directly.
508
+ const analyticsOpt = opts.analytics;
509
+ const analyticsGroup =
510
+ analyticsOpt &&
511
+ !isAnalyticsProvider(analyticsOpt as AnalyticsProvider) &&
512
+ typeof (analyticsOpt as PostHogService).captureEvent !== "function"
513
+ ? (analyticsOpt as {
514
+ provider?: AnalyticsProvider;
515
+ providers?: AnalyticsProvider[];
516
+ defaultProvider?: string;
517
+ })
518
+ : undefined;
519
+
520
+ const analyticsProviders = new AnalyticsProviderRegistry([
521
+ ...analyticsProvidersFromEnv(env, { db, logger }),
522
+ ...(analyticsGroup?.providers ?? []),
523
+ ...(analyticsGroup?.provider ? [analyticsGroup.provider] : []),
524
+ ]);
525
+
526
+ let analytics: AnalyticsProvider | undefined;
527
+ if (analyticsOpt && !analyticsGroup) {
528
+ // Bare provider or legacy service: register and activate it directly.
529
+ analytics = isAnalyticsProvider(analyticsOpt as AnalyticsProvider)
530
+ ? (analyticsOpt as AnalyticsProvider)
531
+ : wrapLegacyAnalyticsService(analyticsOpt as PostHogService);
532
+ analyticsProviders.register(analytics);
533
+ } else {
534
+ const activeId = analyticsGroup?.defaultProvider ?? env.ANALYTICS_PROVIDER;
535
+ analytics = analyticsProviders.get(activeId);
536
+ if (analyticsGroup?.defaultProvider && !analytics) {
537
+ throw new Error(
538
+ `analytics.defaultProvider "${analyticsGroup.defaultProvider}" is not a registered analytics provider (registered: ${analyticsProviders
539
+ .getAll()
540
+ .map((p) => p.meta.id)
541
+ .join(", ")})`,
542
+ );
543
+ }
544
+ }
545
+
546
+ // Person reads need a privileged credential on most platforms (PostHog: a
547
+ // personal API key — the phc_ project key is write-only by design). Surface
548
+ // the degraded mode once at boot instead of letting tz resolution silently
549
+ // fall back for months.
550
+ // OAuth-capable providers resolve their credential ASYNC (the env factory
551
+ // logs the truthful nudge after the load settles) — a sync check here would
552
+ // log a false "DISABLED" on every boot of a connected instance.
553
+ if (
554
+ analytics &&
555
+ !analytics.capabilities.oauth &&
556
+ !analytics.capabilities.personReads
557
+ ) {
558
+ logger.info(
559
+ `analytics provider "${analytics.meta.id}" has person reads DISABLED — ` +
560
+ "timezone resolution falls back to contact properties. For PostHog, " +
561
+ "set POSTHOG_PERSONAL_API_KEY or run `hogsend connect posthog`. " +
562
+ "Docs: https://hogsend.com/docs/guides/analytics-access",
563
+ );
564
+ }
466
565
 
467
566
  // Expose the resolved analytics instance to the module-level task-execution
468
567
  // sites that have no client reference. Its role is NARROW (see the
469
568
  // `analytics?` option doc): the identity PULL (`getPersonProperties` for tz
470
- // resolution in the journey durable task) plus the opt-in
471
- // `bucket.syncToPostHog` person-property mirror — NOT the outbound catalog
472
- // firing path (that is the destinations spine). `createHogsendClient` runs in
473
- // both the API and worker, so this is installed before any worker task runs.
474
- // May be undefined (no POSTHOG_API_KEY) — the reads stay no-ops.
569
+ // resolution in the journey durable task) plus person writes (the opt-in
570
+ // `bucket.syncToPostHog` mirror) — NOT the outbound catalog firing path
571
+ // (that is the destinations spine). `createHogsendClient` runs in both the
572
+ // API and worker, so this is installed before any worker task runs. May be
573
+ // undefined (no provider configured) — the reads stay no-ops.
475
574
  setAnalytics(analytics);
476
575
 
477
576
  // Build + install the outbound DESTINATION registry (Phase 3) the
@@ -527,6 +626,7 @@ export function createHogsendClient(
527
626
  emailProvider: provider,
528
627
  domainStatus,
529
628
  templates,
629
+ analyticsProviders,
530
630
  analytics,
531
631
  registry,
532
632
  bucketRegistry,
@@ -1,3 +1,4 @@
1
+ import type { OutboundPayloads } from "../../lib/outbound.js";
1
2
  import { WEBHOOK_EVENT_TYPES } from "../../lib/webhook-signing.js";
2
3
  import { defineDestination } from "../define-destination.js";
3
4
 
@@ -16,6 +17,60 @@ interface PostHogConfig {
16
17
  * be remapped this way; absent or unmapped keys pass through unchanged.
17
18
  */
18
19
  eventNames?: Record<string, string>;
20
+ /**
21
+ * Person-property propagation (the contact → analytics-person rail). When
22
+ * true, `contact.created` / `contact.updated` events become `$set` captures
23
+ * of the contact's `properties` under the contact's canonical key — the
24
+ * SAME distinct id the identify loop uses — so PostHog person profiles
25
+ * accumulate contact truth (plan, role, lifecycle stage…) and cohorts can
26
+ * segment on it. `contact.unsubscribed` (scope `all`) sets
27
+ * `hogsend_unsubscribed: true`.
28
+ *
29
+ * Privacy posture: ONLY `contact.properties` syncs — never email or any
30
+ * other identifier. Anything but a boolean `true` (config is a loose jsonb
31
+ * bag) leaves `contact.*` events SKIPPED entirely — they carry no
32
+ * `userId`/`to`, so the generic capture branch could never address them
33
+ * correctly anyway.
34
+ */
35
+ syncPersons?: boolean;
36
+ /**
37
+ * The person property a scope-`all` unsubscribe sets (default
38
+ * `hogsend_unsubscribed`) — overridable like the bucket mirror's
39
+ * `postHogPropertyKey`, so operators can match their own naming scheme.
40
+ */
41
+ unsubscribedPropertyKey?: string;
42
+ }
43
+
44
+ /**
45
+ * The one place the PostHog capture request is built — all three transform
46
+ * branches (person `$set`, `email.action`, generic catalog capture) share it,
47
+ * so a change to the capture wire shape happens once.
48
+ */
49
+ function captureRequest(opts: {
50
+ host: string;
51
+ apiKey: string;
52
+ event: string;
53
+ distinctId: string | undefined;
54
+ timestamp: string;
55
+ properties: Record<string, unknown>;
56
+ }): {
57
+ url: string;
58
+ method: string;
59
+ headers: Record<string, string>;
60
+ body: string;
61
+ } {
62
+ return {
63
+ url: `${opts.host}/capture/`,
64
+ method: "POST",
65
+ headers: { "Content-Type": "application/json" },
66
+ body: JSON.stringify({
67
+ api_key: opts.apiKey,
68
+ event: opts.event,
69
+ distinct_id: opts.distinctId,
70
+ timestamp: opts.timestamp,
71
+ properties: { ...opts.properties, $lib: "hogsend" },
72
+ }),
73
+ };
19
74
  }
20
75
 
21
76
  /**
@@ -47,6 +102,62 @@ export const posthogDestination = defineDestination({
47
102
  );
48
103
  }
49
104
  const host = config.host ?? "https://us.i.posthog.com";
105
+
106
+ // Person-property propagation: `contact.*` events carry a contact payload
107
+ // (id/externalId/email/properties), NOT the userId/to identity chain the
108
+ // generic capture branch keys on — so they are handled here exclusively
109
+ // and SKIPPED (null) when `config.syncPersons` is off.
110
+ if (envelope.type.startsWith("contact.")) {
111
+ // Strict `=== true`: config is a loose jsonb bag, and a stray string
112
+ // value ("false") must not enable the sync.
113
+ if (config.syncPersons !== true) return null;
114
+
115
+ const setCapture = (distinctId: string, set: Record<string, unknown>) =>
116
+ captureRequest({
117
+ host,
118
+ apiKey: config.apiKey as string,
119
+ event: "$set",
120
+ distinctId,
121
+ timestamp: envelope.timestamp,
122
+ properties: { $set: set },
123
+ });
124
+
125
+ if (
126
+ envelope.type === "contact.created" ||
127
+ envelope.type === "contact.updated"
128
+ ) {
129
+ const contact =
130
+ envelope.data as unknown as OutboundPayloads["contact.updated"];
131
+ const props = contact.properties ?? {};
132
+ // Nothing to propagate — a successful no-op, not a delivery failure.
133
+ if (Object.keys(props).length === 0) return null;
134
+ // The contact's canonical key (externalId ?? id) — the same distinct
135
+ // id the identify loop and hs_t stitch use, so the $set lands on the
136
+ // person the contact's web sessions and email events already share.
137
+ // Known limitation: the serialized payload omits anonymousId, so an
138
+ // anonymous-keyed contact syncs under its row id rather than its
139
+ // anonymous key — fixed properly when contact.* payloads grow a
140
+ // first-class `contactKey` field.
141
+ return setCapture(contact.externalId ?? contact.id, props);
142
+ }
143
+
144
+ if (envelope.type === "contact.unsubscribed") {
145
+ const data =
146
+ envelope.data as unknown as OutboundPayloads["contact.unsubscribed"];
147
+ // Category-scoped opt-outs are too granular for a person flag, and a
148
+ // payload without externalId can't be addressed safely (the canonical
149
+ // key of an email-only contact is its row id, which this payload
150
+ // doesn't carry — guessing by email would mint a wrong person).
151
+ if (data.scope !== "all" || !data.externalId) return null;
152
+ const flag = config.unsubscribedPropertyKey ?? "hogsend_unsubscribed";
153
+ return setCapture(data.externalId, { [flag]: true });
154
+ }
155
+
156
+ // contact.deleted: PostHog person deletion is a private-API operation,
157
+ // not a capture — out of scope for this rail.
158
+ return null;
159
+ }
160
+
50
161
  const data = envelope.data as {
51
162
  userId?: string | null;
52
163
  to?: string | null;
@@ -69,38 +180,29 @@ export const posthogDestination = defineDestination({
69
180
  userId: string | null;
70
181
  at: string;
71
182
  };
72
- return {
73
- url: `${host}/capture/`,
74
- method: "POST",
75
- headers: { "Content-Type": "application/json" },
76
- body: JSON.stringify({
77
- api_key: config.apiKey,
78
- event: action.event,
79
- distinct_id: distinctId,
80
- timestamp: envelope.timestamp,
81
- properties: {
82
- ...(action.properties ?? {}),
83
- emailSendId: action.emailSendId,
84
- templateKey: action.templateKey,
85
- linkId: action.linkId,
86
- $lib: "hogsend",
87
- },
88
- }),
89
- };
183
+ return captureRequest({
184
+ host,
185
+ apiKey: config.apiKey,
186
+ event: action.event,
187
+ distinctId,
188
+ timestamp: envelope.timestamp,
189
+ properties: {
190
+ ...(action.properties ?? {}),
191
+ emailSendId: action.emailSendId,
192
+ templateKey: action.templateKey,
193
+ linkId: action.linkId,
194
+ },
195
+ });
90
196
  }
91
197
  // Optional event-name remap (identity by default).
92
198
  const eventName = config.eventNames?.[envelope.type] ?? envelope.type;
93
- return {
94
- url: `${host}/capture/`,
95
- method: "POST",
96
- headers: { "Content-Type": "application/json" },
97
- body: JSON.stringify({
98
- api_key: config.apiKey,
99
- event: eventName,
100
- distinct_id: distinctId,
101
- timestamp: envelope.timestamp,
102
- properties: { ...envelope.data, $lib: "hogsend" },
103
- }),
104
- };
199
+ return captureRequest({
200
+ host,
201
+ apiKey: config.apiKey,
202
+ event: eventName,
203
+ distinctId,
204
+ timestamp: envelope.timestamp,
205
+ properties: envelope.data as Record<string, unknown>,
206
+ });
105
207
  },
106
208
  });
package/src/env.ts CHANGED
@@ -134,6 +134,21 @@ export const env = createEnv({
134
134
  CLIENT_MIGRATIONS_FOLDER: z.string().min(1).optional(),
135
135
  POSTHOG_API_KEY: z.string().min(1).optional(),
136
136
  POSTHOG_HOST: z.string().url().optional(),
137
+ // Personal API key (scoped `person:read`, optionally `person:write`) for
138
+ // person-property READS on the private API. The phc_ project key cannot
139
+ // read — it is public + write-only by PostHog's design. Without this,
140
+ // person reads soft-fail and timezone resolution falls back to contact
141
+ // properties. See the "Analytics access" docs page.
142
+ POSTHOG_PERSONAL_API_KEY: z.string().min(1).optional(),
143
+ // PostHog project id for environment-scoped private endpoints. Discovered
144
+ // automatically via GET /api/projects/@current/ when unset.
145
+ POSTHOG_PROJECT_ID: z.string().min(1).optional(),
146
+ // Private (app) API host override. Defaults to POSTHOG_HOST with the
147
+ // `.i.` ingestion label stripped (eu.i.posthog.com → eu.posthog.com).
148
+ POSTHOG_PRIVATE_HOST: z.string().url().optional(),
149
+ // Selects the ACTIVE analytics provider id out of the registry (env
150
+ // presets + consumer-registered providers). Mirrors EMAIL_PROVIDER.
151
+ ANALYTICS_PROVIDER: z.string().min(1).default("posthog"),
137
152
  POSTHOG_WEBHOOK_SECRET: z.string().min(1).optional(),
138
153
  // When true AND POSTHOG_API_KEY is set, the engine idempotently auto-seeds
139
154
  // ONE kind="posthog" webhook endpoint subscribed to the email funnel so the
package/src/index.ts CHANGED
@@ -37,7 +37,11 @@ export * from "@hogsend/core";
37
37
  // omitted here: the engine's public `SendEmailOptions` is the high-level
38
38
  // journey-facing send options from `./lib/email.js`; the provider-contract
39
39
  // `SendEmailOptions` remains available via `@hogsend/core`.)
40
- export { defineEmailProvider, WebhookHandshakeSignal } from "@hogsend/core";
40
+ export {
41
+ defineAnalyticsProvider,
42
+ defineEmailProvider,
43
+ WebhookHandshakeSignal,
44
+ } from "@hogsend/core";
41
45
  export {
42
46
  BucketRegistry,
43
47
  JourneyRegistry,
@@ -136,6 +140,9 @@ export {
136
140
  getJourneyRegistrySingleton,
137
141
  setJourneyRegistry,
138
142
  } from "./journeys/registry-singleton.js";
143
+ // --- Analytics provider registry (the analytics sibling) ---
144
+ export { AnalyticsProviderRegistry } from "./lib/analytics-provider-registry.js";
145
+ export { analyticsProvidersFromEnv } from "./lib/analytics-providers-from-env.js";
139
146
  // --- Auth ---
140
147
  export {
141
148
  type Auth,
@@ -220,6 +227,18 @@ export {
220
227
  // --- Logging ---
221
228
  export { createLogger, type Logger } from "./lib/logger.js";
222
229
  export { createTrackedMailer } from "./lib/mailer.js";
230
+ // --- OAuth token manager (provider access-token cache + refresh) ---
231
+ export {
232
+ ABSENT_RECHECK_MS,
233
+ type CredentialState,
234
+ type CredentialStore,
235
+ createTokenManager,
236
+ EXPIRY_SKEW_MS,
237
+ FAILURE_BACKOFF_MS,
238
+ HOGSEND_POSTHOG_CLIENT_ID,
239
+ oauthCredentialPayloadSchema,
240
+ type TokenManager,
241
+ } from "./lib/oauth-token-manager.js";
223
242
  // --- Outbound webhooks: emit spine (Section 1.4) ---
224
243
  export {
225
244
  emitOutbound,
@@ -228,6 +247,18 @@ export {
228
247
  type OutboundPayloads,
229
248
  } from "./lib/outbound.js";
230
249
  export { getPostHog } from "./lib/posthog.js";
250
+ // --- Provider credentials (encrypted-at-rest OAuth token store) ---
251
+ export {
252
+ type CredentialKind,
253
+ type DecryptedProviderCredential,
254
+ deleteProviderCredential,
255
+ getProviderCredential,
256
+ type OAuthCredentialPayload,
257
+ ProviderCredentialDecryptError,
258
+ type ProviderCredentialMeta,
259
+ saveProviderCredential,
260
+ toCredentialMeta,
261
+ } from "./lib/provider-credentials.js";
231
262
  export {
232
263
  type AuthSecondaryStorage,
233
264
  createRedisSecondaryStorage,
@@ -235,6 +266,8 @@ export {
235
266
  } from "./lib/redis.js";
236
267
  // --- Self-service password reset (engine-owned, self-contained email) ---
237
268
  export { sendResetPasswordEmail } from "./lib/reset-email.js";
269
+ // --- PostHog destination seed (idempotent; ENABLE_POSTHOG_DESTINATION) ---
270
+ export { seedPostHogDestination } from "./lib/seed-posthog-destination.js";
238
271
  export {
239
272
  type ConfirmSemanticClickInput,
240
273
  type ConfirmSemanticClickResult,
@@ -0,0 +1,68 @@
1
+ import type { AnalyticsProvider, PostHogService } from "@hogsend/core";
2
+
3
+ /**
4
+ * Wrap a legacy `PostHogService` (the deprecated PostHog-shaped interface
5
+ * accepted by `createHogsendClient({ analytics })` since before the neutral
6
+ * `AnalyticsProvider` contract existed) so it satisfies the contract the
7
+ * engine now speaks internally. Capabilities are assumed-on: a hand-built
8
+ * service predates capability reporting, and every method is best-effort
9
+ * anyway.
10
+ *
11
+ * Mapping notes:
12
+ * - `setPersonProperties.set` → `identify(distinctId, set)` (the legacy $set
13
+ * path). `setOnce` ALSO maps to `identify` — legacy services have no
14
+ * set-once wire, so overwrite semantics apply; `unset` maps to the legacy
15
+ * raw `$set` capture with `$unset`, mirroring what the bucket sync used to
16
+ * emit directly.
17
+ */
18
+ export function wrapLegacyAnalyticsService(
19
+ service: PostHogService,
20
+ ): AnalyticsProvider {
21
+ return {
22
+ meta: {
23
+ id: "custom",
24
+ name: "Custom analytics service (legacy PostHogService shape)",
25
+ },
26
+ capabilities: { personReads: true, personWrites: true },
27
+
28
+ getPersonProperties(distinctId) {
29
+ return service.getPersonProperties(distinctId);
30
+ },
31
+
32
+ async setPersonProperties({ distinctId, set, setOnce, unset }) {
33
+ const merged = { ...(setOnce ?? {}), ...(set ?? {}) };
34
+ if (Object.keys(merged).length > 0) {
35
+ service.identify(distinctId, merged);
36
+ }
37
+ if (unset?.length) {
38
+ service.captureEvent({
39
+ distinctId,
40
+ event: "$set",
41
+ properties: { $unset: unset },
42
+ });
43
+ }
44
+ },
45
+
46
+ capture(opts) {
47
+ service.captureEvent(opts);
48
+ },
49
+
50
+ async shutdown() {
51
+ await service.shutdown();
52
+ },
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Runtime discrimination for the `analytics` option union: a neutral
58
+ * `AnalyticsProvider` carries `meta` + `capture`; the legacy `PostHogService`
59
+ * carries `captureEvent` and no `meta`.
60
+ */
61
+ export function isAnalyticsProvider(
62
+ value: AnalyticsProvider | PostHogService,
63
+ ): value is AnalyticsProvider {
64
+ return (
65
+ typeof (value as AnalyticsProvider).capture === "function" &&
66
+ typeof (value as AnalyticsProvider).meta === "object"
67
+ );
68
+ }
@@ -0,0 +1,35 @@
1
+ import type { AnalyticsProvider } from "@hogsend/core";
2
+
3
+ /**
4
+ * Container-held registry of analytics providers, keyed by `provider.meta.id`
5
+ * — the analytics sibling of `EmailProviderRegistry`. The container picks ONE
6
+ * `active` provider out of it (env `ANALYTICS_PROVIDER` /
7
+ * `analytics.defaultProvider`, default `"posthog"`) for the identity PULL,
8
+ * person writes, and capture.
9
+ *
10
+ * Keyed with last-writer-wins, so a consumer-supplied provider of the same id
11
+ * overrides an env preset of that id.
12
+ */
13
+ export class AnalyticsProviderRegistry {
14
+ private byId = new Map<string, AnalyticsProvider>();
15
+
16
+ constructor(providers: AnalyticsProvider[] = []) {
17
+ for (const provider of providers) this.register(provider);
18
+ }
19
+
20
+ register(provider: AnalyticsProvider): void {
21
+ this.byId.set(provider.meta.id, provider);
22
+ }
23
+
24
+ get(id: string): AnalyticsProvider | undefined {
25
+ return this.byId.get(id);
26
+ }
27
+
28
+ getAll(): AnalyticsProvider[] {
29
+ return [...this.byId.values()];
30
+ }
31
+
32
+ count(): number {
33
+ return this.byId.size;
34
+ }
35
+ }