@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.
@@ -0,0 +1,79 @@
1
+ import type { AnalyticsProvider } from "@hogsend/core";
2
+ import type { Database } from "@hogsend/db";
3
+ import {
4
+ createPostHogProvider,
5
+ type PostHogAuthTokenAccessor,
6
+ } from "@hogsend/plugin-posthog";
7
+ import type { env as envSchema } from "../env.js";
8
+ import type { Logger } from "./logger.js";
9
+ import { createTokenManager } from "./oauth-token-manager.js";
10
+ import { getRedis } from "./redis.js";
11
+
12
+ /**
13
+ * Env-driven analytics-provider presets — the analytics sibling of
14
+ * `emailProvidersFromEnv`. PostHog is built when `POSTHOG_API_KEY` is set;
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.
20
+ *
21
+ * Consumer-supplied providers (`analytics.providers` / `analytics.provider`)
22
+ * merge AFTER these in the registry, so a consumer build of the same id wins.
23
+ */
24
+ export function analyticsProvidersFromEnv(
25
+ env: typeof envSchema,
26
+ deps?: { db?: Database; logger?: Logger },
27
+ ): AnalyticsProvider[] {
28
+ const providers: AnalyticsProvider[] = [];
29
+
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
+ }
65
+ providers.push(
66
+ createPostHogProvider({
67
+ apiKey: env.POSTHOG_API_KEY,
68
+ host: env.POSTHOG_HOST,
69
+ personalApiKey: env.POSTHOG_PERSONAL_API_KEY,
70
+ projectId: env.POSTHOG_PROJECT_ID,
71
+ privateHost: env.POSTHOG_PRIVATE_HOST,
72
+ redis: getRedis(),
73
+ authToken,
74
+ }),
75
+ );
76
+ }
77
+
78
+ return providers;
79
+ }
@@ -1,4 +1,4 @@
1
- import type { PostHogService } from "@hogsend/core";
1
+ import type { AnalyticsProvider } from "@hogsend/core";
2
2
  import { createOptionalSingleton } from "./singleton.js";
3
3
 
4
4
  /**
@@ -24,7 +24,7 @@ import { createOptionalSingleton } from "./singleton.js";
24
24
  * container resolves `analytics` to `undefined` and installs it here, so every
25
25
  * read remains a no-op exactly as before — hence the optional singleton variant.
26
26
  */
27
- const singleton = createOptionalSingleton<PostHogService>();
27
+ const singleton = createOptionalSingleton<AnalyticsProvider>();
28
28
 
29
29
  export const setAnalytics = singleton.set;
30
30
  export const getAnalytics = singleton.get;
@@ -42,16 +42,18 @@ export function syncBucketToPostHog(opts: {
42
42
 
43
43
  try {
44
44
  if (kind === "entered") {
45
- // $set { key: true } — mirrors plugin-posthog identify() ($set path).
46
- posthog.identify(userId, { [propertyKey]: true });
45
+ // set { key: true } — the provider's person-write wire ($set on PostHog).
46
+ void posthog.setPersonProperties({
47
+ distinctId: userId,
48
+ set: { [propertyKey]: true },
49
+ });
47
50
  } else {
48
- // $unset [key] — RECOMMENDED on leave (Section 12). The property is absent
51
+ // unset [key] — RECOMMENDED on leave (Section 12). The property is absent
49
52
  // unless the user is currently a member, so both `key = true` and
50
53
  // `key is set` cohorts behave correctly.
51
- posthog.captureEvent({
54
+ void posthog.setPersonProperties({
52
55
  distinctId: userId,
53
- event: "$set",
54
- properties: { $unset: [propertyKey] },
56
+ unset: [propertyKey],
55
57
  });
56
58
  }
57
59
  } catch (err) {
@@ -0,0 +1,353 @@
1
+ import type { Database } from "@hogsend/db";
2
+ import { z } from "zod";
3
+ import type { Logger } from "./logger.js";
4
+ import {
5
+ getProviderCredential,
6
+ type OAuthCredentialPayload,
7
+ saveProviderCredential,
8
+ } from "./provider-credentials.js";
9
+
10
+ /**
11
+ * The CIMD document URL — doubles as the OAuth `client_id` (PostHog public
12
+ * client, `token_endpoint_auth_method: "none"`). Sent on every refresh.
13
+ *
14
+ * LOCKSTEP (M5): this URL is deliberately re-typed in THREE places — here,
15
+ * the CLI's `POSTHOG_CLIENT_ID` (`packages/cli/src/lib/oauth.ts`), and the
16
+ * `client_id` field inside the hosted CIMD document
17
+ * (`apps/docs/public/.well-known/hogsend-posthog-client.json`). The CLI has
18
+ * no engine dependency, so there is no single importable source of truth;
19
+ * grep all three before changing any of them.
20
+ */
21
+ export const HOGSEND_POSTHOG_CLIENT_ID =
22
+ "https://hogsend.com/.well-known/hogsend-posthog-client.json";
23
+
24
+ /** Refresh when fewer than 60s of access-token life remain. */
25
+ export const EXPIRY_SKEW_MS = 60_000;
26
+ /** Negative-cache window for "no credential row" — runtime-connect pickup. */
27
+ export const ABSENT_RECHECK_MS = 30_000;
28
+ /** Minimum gap between failed refresh attempts. */
29
+ export const FAILURE_BACKOFF_MS = 60_000;
30
+ const REFRESH_TIMEOUT_MS = 10_000;
31
+
32
+ // The canonical OAuth credential payload (SYNTHESIS §0) — keep textually
33
+ // identical to the admin route's PUT body schema
34
+ // (`routes/admin/provider-credentials.ts`). Output type matches the store's
35
+ // `OAuthCredentialPayload` interface exactly.
36
+ export const oauthCredentialPayloadSchema = z.object({
37
+ accessToken: z.string().min(1),
38
+ refreshToken: z.string().min(1),
39
+ expiresAt: z.string().datetime({ offset: true }),
40
+ tokenEndpoint: z.string().url(),
41
+ clientId: z.string().url(),
42
+ scopes: z.array(z.string()).default([]),
43
+ scopedTeams: z.array(z.number().int()).default([]),
44
+ scopedOrganizations: z.array(z.string()).default([]),
45
+ });
46
+
47
+ /** Test/cross-lane seam over the credential store. */
48
+ export interface CredentialStore {
49
+ /** Decrypted payload JSON of the (providerId, "oauth") row, or null. */
50
+ load(): Promise<Record<string, unknown> | null>;
51
+ save(payload: OAuthCredentialPayload): Promise<void>;
52
+ }
53
+
54
+ export type CredentialState = "unknown" | "present" | "absent";
55
+
56
+ export interface TokenManager {
57
+ /**
58
+ * Resolve a live access token, refreshing if necessary. NEVER throws.
59
+ * Returns null when no usable credential exists (absent, malformed, or
60
+ * refresh failed and the old token is hard-expired) — callers degrade.
61
+ */
62
+ getAccessToken(): Promise<string | null>;
63
+ /** Synchronous best-known state — drives `capabilities.personReads`. */
64
+ credentialState(): CredentialState;
65
+ /** Load-only warm-up (no refresh). Fire-and-forget at boot. */
66
+ prime(): Promise<void>;
67
+ /**
68
+ * Drop the in-memory payload and force a refresh attempt on the next
69
+ * getAccessToken (still subject to failure backoff).
70
+ */
71
+ invalidate(): void;
72
+ }
73
+
74
+ interface RefreshResponseBody {
75
+ access_token?: unknown;
76
+ refresh_token?: unknown;
77
+ expires_in?: unknown;
78
+ scope?: unknown;
79
+ scoped_teams?: unknown;
80
+ scoped_organizations?: unknown;
81
+ error?: unknown;
82
+ }
83
+
84
+ function defaultStore(db: Database, providerId: string): CredentialStore {
85
+ return {
86
+ load: async () =>
87
+ ((await getProviderCredential(db, providerId, "oauth"))?.payload ??
88
+ null) as Record<string, unknown> | null,
89
+ save: async (payload) => {
90
+ await saveProviderCredential(db, { providerId, payload });
91
+ },
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Per-process OAuth access-token manager: in-memory cache + single-flight
97
+ * refresh against the credential payload's stored `tokenEndpoint`, persisting
98
+ * rotations back through the credential store. One instance per process (API
99
+ * and worker each have their own) — the DB row is the shared truth, so the
100
+ * manager ALWAYS re-loads from the store before refreshing (a sibling process
101
+ * may have refreshed first; adopting its result avoids a cross-process
102
+ * stampede against an endpoint whose rotation semantics are undocumented).
103
+ */
104
+ export function createTokenManager(opts: {
105
+ providerId: string;
106
+ db?: Database;
107
+ store?: CredentialStore;
108
+ logger?: Logger;
109
+ fetchImpl?: typeof fetch;
110
+ now?: () => number;
111
+ }): TokenManager {
112
+ const { providerId, logger } = opts;
113
+ const resolvedStore =
114
+ opts.store ?? (opts.db ? defaultStore(opts.db, providerId) : undefined);
115
+ if (!resolvedStore) {
116
+ throw new Error("createTokenManager requires db or store");
117
+ }
118
+ // Hoisted inner functions can't see the narrowing above — re-bind.
119
+ const store: CredentialStore = resolvedStore;
120
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
121
+ const now = opts.now ?? Date.now;
122
+
123
+ let payload: OAuthCredentialPayload | null = null;
124
+ let absentCheckedAt: number | null = null;
125
+ let lastFailureAt: number | null = null;
126
+ let warnedFailure = false;
127
+ let warnedInvalid = false;
128
+ let forceRefresh = false;
129
+ let inflight: Promise<string | null> | null = null;
130
+
131
+ const expiresAtMs = (p: OAuthCredentialPayload) => Date.parse(p.expiresAt);
132
+
133
+ // Warn-once: first occurrence per failure streak logs at warn (the message
134
+ // carries the reconnect remediation), repeats drop to debug. Latches reset
135
+ // on the next success so a NEW streak warns again.
136
+ const warnOnce = (kind: "failure" | "invalid", message: string) => {
137
+ const latched = kind === "failure" ? warnedFailure : warnedInvalid;
138
+ if (latched) {
139
+ logger?.debug(message);
140
+ return;
141
+ }
142
+ logger?.warn(message);
143
+ if (kind === "failure") warnedFailure = true;
144
+ else warnedInvalid = true;
145
+ };
146
+
147
+ const errMsg = (err: unknown) =>
148
+ err instanceof Error ? err.message : String(err);
149
+
150
+ async function refresh(
151
+ old: OAuthCredentialPayload,
152
+ t: number,
153
+ ): Promise<string | null> {
154
+ let detail: string;
155
+ try {
156
+ const response = await fetchImpl(old.tokenEndpoint, {
157
+ method: "POST",
158
+ headers: {
159
+ // No Authorization header — public client; client_id in the body.
160
+ "Content-Type": "application/x-www-form-urlencoded",
161
+ Accept: "application/json",
162
+ },
163
+ body: new URLSearchParams({
164
+ grant_type: "refresh_token",
165
+ refresh_token: old.refreshToken,
166
+ client_id: old.clientId,
167
+ }).toString(),
168
+ signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
169
+ });
170
+
171
+ if (response.ok) {
172
+ const body = (await response.json()) as RefreshResponseBody;
173
+ if (
174
+ typeof body.access_token === "string" &&
175
+ typeof body.expires_in === "number"
176
+ ) {
177
+ const next: OAuthCredentialPayload = {
178
+ ...old,
179
+ accessToken: body.access_token,
180
+ // ROTATION RULE: PostHog's refresh-token rotation behavior is
181
+ // undocumented — always store a returned refresh token, KEEP the
182
+ // old one when the response omits it.
183
+ refreshToken:
184
+ typeof body.refresh_token === "string"
185
+ ? body.refresh_token
186
+ : old.refreshToken,
187
+ expiresAt: new Date(t + body.expires_in * 1000).toISOString(),
188
+ scopes:
189
+ typeof body.scope === "string"
190
+ ? body.scope.split(" ")
191
+ : old.scopes,
192
+ scopedTeams: Array.isArray(body.scoped_teams)
193
+ ? (body.scoped_teams as number[])
194
+ : old.scopedTeams,
195
+ scopedOrganizations: Array.isArray(body.scoped_organizations)
196
+ ? (body.scoped_organizations as string[])
197
+ : old.scopedOrganizations,
198
+ };
199
+ try {
200
+ await store.save(next);
201
+ } catch (err) {
202
+ // Persistence hiccup must not kill the send path — adopt the
203
+ // refreshed token in memory; a process restart re-refreshes.
204
+ logger?.warn(
205
+ `${providerId} oauth credential save failed after refresh ` +
206
+ `(token kept in memory): ${errMsg(err)}`,
207
+ );
208
+ }
209
+ payload = next;
210
+ lastFailureAt = null;
211
+ warnedFailure = false;
212
+ forceRefresh = false;
213
+ return next.accessToken;
214
+ }
215
+ detail = "unparseable token response";
216
+ } else {
217
+ detail = `HTTP ${response.status}`;
218
+ try {
219
+ const errBody = (await response.json()) as RefreshResponseBody;
220
+ if (typeof errBody.error === "string") detail = errBody.error;
221
+ } catch {
222
+ // keep the HTTP status detail
223
+ }
224
+ }
225
+ } catch (err) {
226
+ detail = errMsg(err);
227
+ }
228
+
229
+ lastFailureAt = t;
230
+ forceRefresh = false;
231
+ warnOnce(
232
+ "failure",
233
+ `${providerId} oauth token refresh failed (${detail}) — analytics ` +
234
+ "reads degrade (personal key fallback or disabled); run " +
235
+ `\`hogsend connect ${providerId}\` to reconnect`,
236
+ );
237
+ // Inside the skew window the old token is technically still live.
238
+ return expiresAtMs(old) > t ? old.accessToken : null;
239
+ }
240
+
241
+ async function run(): Promise<string | null> {
242
+ const t = now();
243
+
244
+ // 1. Fresh in-memory token → fast path, no IO.
245
+ if (!forceRefresh && payload && expiresAtMs(payload) - EXPIRY_SKEW_MS > t) {
246
+ return payload.accessToken;
247
+ }
248
+
249
+ // 2. Known-absent within the negative-cache window → cheap null.
250
+ if (
251
+ !payload &&
252
+ absentCheckedAt !== null &&
253
+ t - absentCheckedAt < ABSENT_RECHECK_MS
254
+ ) {
255
+ return null;
256
+ }
257
+
258
+ // 3. (Re)load from the store. ALWAYS re-load before refreshing — another
259
+ // process (API vs worker) may have already refreshed; adopting its row
260
+ // avoids a cross-process refresh stampede.
261
+ let raw: Record<string, unknown> | null;
262
+ try {
263
+ raw = await store.load();
264
+ } catch (err) {
265
+ // Surfaces ProviderCredentialDecryptError.message verbatim — it
266
+ // carries the reconnect remediation.
267
+ warnOnce(
268
+ "invalid",
269
+ `${providerId} oauth credential load failed: ${errMsg(err)}`,
270
+ );
271
+ payload = null;
272
+ absentCheckedAt = t;
273
+ return null;
274
+ }
275
+ if (raw === null) {
276
+ // Absent is NORMAL (provider not connected) — no warn.
277
+ payload = null;
278
+ absentCheckedAt = t;
279
+ return null;
280
+ }
281
+ const parsed = oauthCredentialPayloadSchema.safeParse(raw);
282
+ if (!parsed.success) {
283
+ warnOnce(
284
+ "invalid",
285
+ `${providerId} oauth credential payload is malformed — re-run ` +
286
+ `\`hogsend connect ${providerId}\``,
287
+ );
288
+ payload = null;
289
+ absentCheckedAt = t;
290
+ return null;
291
+ }
292
+ payload = parsed.data;
293
+ absentCheckedAt = null;
294
+ warnedInvalid = false;
295
+
296
+ // 4. Reloaded token already fresh (e.g. the other process refreshed).
297
+ if (!forceRefresh && expiresAtMs(payload) - EXPIRY_SKEW_MS > t) {
298
+ return payload.accessToken;
299
+ }
300
+
301
+ // 5. Failure backoff: don't hammer the endpoint. Inside the skew window
302
+ // the old token is technically still live — return it.
303
+ if (lastFailureAt !== null && t - lastFailureAt < FAILURE_BACKOFF_MS) {
304
+ return expiresAtMs(payload) > t ? payload.accessToken : null;
305
+ }
306
+
307
+ // 6. Refresh.
308
+ return refresh(payload, t);
309
+ }
310
+
311
+ return {
312
+ getAccessToken() {
313
+ if (inflight) return inflight;
314
+ inflight = run().finally(() => {
315
+ inflight = null;
316
+ });
317
+ return inflight;
318
+ },
319
+
320
+ credentialState() {
321
+ return payload
322
+ ? "present"
323
+ : absentCheckedAt !== null
324
+ ? "absent"
325
+ : "unknown";
326
+ },
327
+
328
+ // Load-only by design: a refreshing prime would make the API and worker
329
+ // race a simultaneous refresh on every deploy. The first real read pays
330
+ // the refresh; step 3's reload-before-refresh heals the residual race.
331
+ async prime() {
332
+ if (payload || absentCheckedAt !== null) return;
333
+ try {
334
+ const raw = await store.load();
335
+ if (raw === null) {
336
+ absentCheckedAt = now();
337
+ return;
338
+ }
339
+ const parsed = oauthCredentialPayloadSchema.safeParse(raw);
340
+ if (parsed.success) payload = parsed.data;
341
+ else absentCheckedAt = now();
342
+ } catch {
343
+ absentCheckedAt = now();
344
+ }
345
+ },
346
+
347
+ invalidate() {
348
+ payload = null;
349
+ absentCheckedAt = null;
350
+ forceRefresh = true;
351
+ },
352
+ };
353
+ }
@@ -4,12 +4,28 @@ import { getRedis } from "./redis.js";
4
4
 
5
5
  let _posthog: PostHogService | undefined;
6
6
 
7
+ /**
8
+ * Lazy PostHog service singleton for STANDALONE consumer imports (journeys
9
+ * calling `getPostHog()` for capture/identify/flags). Reads process.env
10
+ * directly so it works without a container reference.
11
+ *
12
+ * Person READS additionally require `POSTHOG_PERSONAL_API_KEY` (the phc_
13
+ * project key is write-only by PostHog's design); without it
14
+ * `getPersonProperties` soft-fails to `{}`.
15
+ *
16
+ * The engine's own analytics path now flows through the neutral
17
+ * `AnalyticsProvider` registry (see `analyticsProvidersFromEnv` /
18
+ * `createHogsendClient`'s `analytics` option) — this stays for consumer code.
19
+ */
7
20
  export function getPostHog(): PostHogService | undefined {
8
21
  if (!process.env.POSTHOG_API_KEY) return undefined;
9
22
  if (!_posthog) {
10
23
  _posthog = createPostHogService({
11
24
  apiKey: process.env.POSTHOG_API_KEY,
12
25
  host: process.env.POSTHOG_HOST,
26
+ personalApiKey: process.env.POSTHOG_PERSONAL_API_KEY,
27
+ projectId: process.env.POSTHOG_PROJECT_ID,
28
+ privateHost: process.env.POSTHOG_PRIVATE_HOST,
13
29
  redis: getRedis(),
14
30
  });
15
31
  }