@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PostHog OAuth scope set Hogsend requests during
|
|
3
|
+
* `hogsend connect posthog`. Front-loaded beyond the webhook loop's needs
|
|
4
|
+
* (which requires only `hog_function:write` plus the minted webhook secret)
|
|
5
|
+
* so future read/write features activate without forcing a reconnect. Every
|
|
6
|
+
* name is validated against PostHog's published `scopes_supported`.
|
|
7
|
+
*
|
|
8
|
+
* LOCKSTEP (3 places — no single importable source of truth, the CLI has no
|
|
9
|
+
* engine dependency): this constant, the CLI's `POSTHOG_SCOPES`
|
|
10
|
+
* (`packages/cli/src/lib/oauth.ts`), and the `scope` field of the hosted CIMD
|
|
11
|
+
* document (`apps/docs/public/.well-known/hogsend-posthog-client.json`).
|
|
12
|
+
* PostHog gates the scopes it will grant by the CIMD doc, so grep all three
|
|
13
|
+
* and keep them identical before changing any of them.
|
|
14
|
+
*/
|
|
15
|
+
export const EXPECTED_POSTHOG_SCOPES: string[] = [
|
|
16
|
+
"person:read",
|
|
17
|
+
"person:write",
|
|
18
|
+
"project:read",
|
|
19
|
+
"organization:read",
|
|
20
|
+
"hog_function:read",
|
|
21
|
+
"hog_function:write",
|
|
22
|
+
"feature_flag:read",
|
|
23
|
+
"cohort:read",
|
|
24
|
+
"cohort:write",
|
|
25
|
+
"query:read",
|
|
26
|
+
"insight:read",
|
|
27
|
+
"event_definition:read",
|
|
28
|
+
"property_definition:read",
|
|
29
|
+
];
|