@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 +7 -7
- package/src/container.ts +10 -3
- package/src/destinations/presets/posthog.ts +132 -30
- package/src/index.ts +31 -0
- package/src/lib/analytics-providers-from-env.ts +48 -5
- package/src/lib/oauth-token-manager.ts +353 -0
- package/src/lib/posthog-scopes.ts +29 -0
- package/src/lib/provider-credentials.ts +349 -0
- package/src/lib/provision-posthog-loop.ts +744 -0
- package/src/lib/seed-posthog-destination.ts +38 -7
- package/src/routes/admin/analytics.ts +335 -0
- package/src/routes/admin/index.ts +4 -0
- package/src/routes/admin/provider-credentials.ts +188 -0
- package/src/routes/webhooks/sources.ts +60 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "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.
|
|
44
|
-
"@hogsend/
|
|
45
|
-
"@hogsend/
|
|
46
|
-
"@hogsend/plugin-posthog": "^0.
|
|
47
|
-
"@hogsend/plugin-resend": "^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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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 {
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
}
|