@hogsend/engine 0.19.0 → 0.21.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.19.0",
3
+ "version": "0.21.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.19.0",
44
- "@hogsend/email": "^0.19.0",
45
- "@hogsend/db": "^0.19.0",
46
- "@hogsend/plugin-posthog": "^0.19.0",
47
- "@hogsend/plugin-resend": "^0.19.0"
43
+ "@hogsend/core": "^0.21.0",
44
+ "@hogsend/db": "^0.21.0",
45
+ "@hogsend/email": "^0.21.0",
46
+ "@hogsend/plugin-posthog": "^0.21.0",
47
+ "@hogsend/plugin-resend": "^0.21.0"
48
48
  },
49
49
  "optionalDependencies": {
50
- "@hogsend/plugin-postmark": "^0.19.0"
50
+ "@hogsend/plugin-postmark": "^0.21.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^22.15.3",
package/src/container.ts CHANGED
@@ -518,7 +518,7 @@ export function createHogsendClient(
518
518
  : undefined;
519
519
 
520
520
  const analyticsProviders = new AnalyticsProviderRegistry([
521
- ...analyticsProvidersFromEnv(env),
521
+ ...analyticsProvidersFromEnv(env, { db, logger }),
522
522
  ...(analyticsGroup?.providers ?? []),
523
523
  ...(analyticsGroup?.provider ? [analyticsGroup.provider] : []),
524
524
  ]);
@@ -547,11 +547,18 @@ export function createHogsendClient(
547
547
  // personal API key — the phc_ project key is write-only by design). Surface
548
548
  // the degraded mode once at boot instead of letting tz resolution silently
549
549
  // fall back for months.
550
- if (analytics && !analytics.capabilities.personReads) {
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
+ ) {
551
558
  logger.info(
552
559
  `analytics provider "${analytics.meta.id}" has person reads DISABLED — ` +
553
560
  "timezone resolution falls back to contact properties. For PostHog, " +
554
- "set POSTHOG_PERSONAL_API_KEY (a personal API key scoped person:read). " +
561
+ "set POSTHOG_PERSONAL_API_KEY or run `hogsend connect posthog`. " +
555
562
  "Docs: https://hogsend.com/docs/guides/analytics-access",
556
563
  );
557
564
  }
@@ -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/index.ts CHANGED
@@ -227,6 +227,18 @@ export {
227
227
  // --- Logging ---
228
228
  export { createLogger, type Logger } from "./lib/logger.js";
229
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";
230
242
  // --- Outbound webhooks: emit spine (Section 1.4) ---
231
243
  export {
232
244
  emitOutbound,
@@ -235,6 +247,23 @@ export {
235
247
  type OutboundPayloads,
236
248
  } from "./lib/outbound.js";
237
249
  export { getPostHog } from "./lib/posthog.js";
250
+ // --- PostHog OAuth scopes (front-loaded set; gap-detector source of truth) ---
251
+ export { EXPECTED_POSTHOG_SCOPES } from "./lib/posthog-scopes.js";
252
+ // --- Provider credentials (encrypted-at-rest OAuth token store) ---
253
+ export {
254
+ type CredentialKind,
255
+ type DecryptedProviderCredential,
256
+ type DerivedCredentialPayload,
257
+ deleteProviderCredential,
258
+ getDerivedCredential,
259
+ getProviderCredential,
260
+ type OAuthCredentialPayload,
261
+ ProviderCredentialDecryptError,
262
+ type ProviderCredentialMeta,
263
+ saveDerivedCredential,
264
+ saveProviderCredential,
265
+ toCredentialMeta,
266
+ } from "./lib/provider-credentials.js";
238
267
  export {
239
268
  type AuthSecondaryStorage,
240
269
  createRedisSecondaryStorage,
@@ -242,6 +271,8 @@ export {
242
271
  } from "./lib/redis.js";
243
272
  // --- Self-service password reset (engine-owned, self-contained email) ---
244
273
  export { sendResetPasswordEmail } from "./lib/reset-email.js";
274
+ // --- PostHog destination seed (idempotent; ENABLE_POSTHOG_DESTINATION) ---
275
+ export { seedPostHogDestination } from "./lib/seed-posthog-destination.js";
245
276
  export {
246
277
  type ConfirmSemanticClickInput,
247
278
  type ConfirmSemanticClickResult,
@@ -1,25 +1,67 @@
1
1
  import type { AnalyticsProvider } from "@hogsend/core";
2
- import { createPostHogProvider } from "@hogsend/plugin-posthog";
2
+ import type { Database } from "@hogsend/db";
3
+ import {
4
+ createPostHogProvider,
5
+ type PostHogAuthTokenAccessor,
6
+ } from "@hogsend/plugin-posthog";
3
7
  import type { env as envSchema } from "../env.js";
8
+ import type { Logger } from "./logger.js";
9
+ import { createTokenManager } from "./oauth-token-manager.js";
4
10
  import { getRedis } from "./redis.js";
5
11
 
6
12
  /**
7
13
  * Env-driven analytics-provider presets — the analytics sibling of
8
14
  * `emailProvidersFromEnv`. PostHog is built when `POSTHOG_API_KEY` is set;
9
- * person READS additionally need `POSTHOG_PERSONAL_API_KEY` (the public phc_
10
- * key is write-only by PostHog's design) — without it the provider still
11
- * captures and writes person properties, and reads soft-fail to the engine's
12
- * contact-property fallback.
15
+ * person READS additionally need a privileged credential: an OAuth credential
16
+ * stored via `hogsend connect posthog` (preferred, token-manager-backed) or
17
+ * `POSTHOG_PERSONAL_API_KEY` (the public phc_ key is write-only by PostHog's
18
+ * design) — without either the provider still captures and writes person
19
+ * properties, and reads soft-fail to the engine's contact-property fallback.
13
20
  *
14
21
  * Consumer-supplied providers (`analytics.providers` / `analytics.provider`)
15
22
  * merge AFTER these in the registry, so a consumer build of the same id wins.
16
23
  */
17
24
  export function analyticsProvidersFromEnv(
18
25
  env: typeof envSchema,
26
+ deps?: { db?: Database; logger?: Logger },
19
27
  ): AnalyticsProvider[] {
20
28
  const providers: AnalyticsProvider[] = [];
21
29
 
22
30
  if (env.POSTHOG_API_KEY) {
31
+ // Token-manager-backed accessor: the manager re-checks the DB (30s
32
+ // negative cache), so a credential stored at RUNTIME via
33
+ // `hogsend connect posthog` comes alive without a restart.
34
+ let authToken: PostHogAuthTokenAccessor | undefined;
35
+ if (deps?.db) {
36
+ const tokenManager = createTokenManager({
37
+ db: deps.db,
38
+ providerId: "posthog",
39
+ logger: deps.logger,
40
+ });
41
+ // Load-only warm-up (no refresh, never blocks construction). The
42
+ // person-reads nudge logs HERE, after the load settles — the container
43
+ // can't log it truthfully at boot because capabilities resolve async
44
+ // for OAuth-capable providers (a connected instance would otherwise
45
+ // log "DISABLED" once on every boot).
46
+ const personalKeySet = Boolean(env.POSTHOG_PERSONAL_API_KEY);
47
+ void tokenManager
48
+ .prime()
49
+ .then(() => {
50
+ if (!personalKeySet && tokenManager.credentialState() !== "present") {
51
+ deps.logger?.info(
52
+ 'analytics provider "posthog" has person reads DISABLED — ' +
53
+ "timezone resolution falls back to contact properties. Set " +
54
+ "POSTHOG_PERSONAL_API_KEY or run `hogsend connect posthog`. " +
55
+ "Docs: https://hogsend.com/docs/guides/analytics-access",
56
+ );
57
+ }
58
+ })
59
+ .catch(() => {});
60
+ authToken = {
61
+ getToken: () => tokenManager.getAccessToken(),
62
+ isAvailable: () => tokenManager.credentialState() === "present",
63
+ };
64
+ }
23
65
  providers.push(
24
66
  createPostHogProvider({
25
67
  apiKey: env.POSTHOG_API_KEY,
@@ -28,6 +70,7 @@ export function analyticsProvidersFromEnv(
28
70
  projectId: env.POSTHOG_PROJECT_ID,
29
71
  privateHost: env.POSTHOG_PRIVATE_HOST,
30
72
  redis: getRedis(),
73
+ authToken,
31
74
  }),
32
75
  );
33
76
  }