@hogsend/engine 0.20.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/index.ts +5 -0
- package/src/lib/posthog-scopes.ts +29 -0
- package/src/lib/provider-credentials.ts +110 -11
- package/src/lib/provision-posthog-loop.ts +33 -11
- package/src/routes/admin/analytics.ts +78 -4
- package/src/routes/admin/provider-credentials.ts +5 -1
- 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/db": "^0.
|
|
45
|
-
"@hogsend/email": "^0.
|
|
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/index.ts
CHANGED
|
@@ -247,15 +247,20 @@ export {
|
|
|
247
247
|
type OutboundPayloads,
|
|
248
248
|
} from "./lib/outbound.js";
|
|
249
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";
|
|
250
252
|
// --- Provider credentials (encrypted-at-rest OAuth token store) ---
|
|
251
253
|
export {
|
|
252
254
|
type CredentialKind,
|
|
253
255
|
type DecryptedProviderCredential,
|
|
256
|
+
type DerivedCredentialPayload,
|
|
254
257
|
deleteProviderCredential,
|
|
258
|
+
getDerivedCredential,
|
|
255
259
|
getProviderCredential,
|
|
256
260
|
type OAuthCredentialPayload,
|
|
257
261
|
ProviderCredentialDecryptError,
|
|
258
262
|
type ProviderCredentialMeta,
|
|
263
|
+
saveDerivedCredential,
|
|
259
264
|
saveProviderCredential,
|
|
260
265
|
toCredentialMeta,
|
|
261
266
|
} from "./lib/provider-credentials.js";
|
|
@@ -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
|
+
];
|
|
@@ -16,8 +16,11 @@ import { env } from "../env.js";
|
|
|
16
16
|
* don't, so the two stay independent).
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* "oauth" holds the token grant; "derived" holds server-derived config grabbed
|
|
21
|
+
* during connect. Widen the union further when an "api_key" kind lands.
|
|
22
|
+
*/
|
|
23
|
+
export type CredentialKind = "oauth" | "derived";
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
26
|
* The decrypted shape stored for kind="oauth". Provider-neutral: nothing in
|
|
@@ -39,6 +42,20 @@ export interface OAuthCredentialPayload {
|
|
|
39
42
|
scopedOrganizations: string[];
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The decrypted shape stored for kind="derived": server-derived PostHog config
|
|
47
|
+
* grabbed during connect. `webhookSecret` is minted when env lacks one
|
|
48
|
+
* (load-bearing for the inbound loop — the posthog webhook source resolves it
|
|
49
|
+
* from the store at request time); `projectApiKey` (phc_) is grabbed
|
|
50
|
+
* opportunistically for the OPTIONAL outbound capture path. All fields optional.
|
|
51
|
+
*/
|
|
52
|
+
export interface DerivedCredentialPayload {
|
|
53
|
+
webhookSecret?: string;
|
|
54
|
+
projectApiKey?: string;
|
|
55
|
+
projectId?: string;
|
|
56
|
+
privateHost?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
42
59
|
/** Row metadata — everything EXCEPT token material. Safe to surface. */
|
|
43
60
|
export interface ProviderCredentialMeta {
|
|
44
61
|
providerId: string;
|
|
@@ -85,14 +102,16 @@ function deriveKey(secret: string): Buffer {
|
|
|
85
102
|
return createHash("sha256").update(secret).digest();
|
|
86
103
|
}
|
|
87
104
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Payload-agnostic encrypt: JSON-stringify any value, AES-256-GCM encrypt,
|
|
107
|
+
* return base64url(iv || ciphertext || tag). No shape validation — the kind's
|
|
108
|
+
* own encrypt helper owns that.
|
|
109
|
+
*/
|
|
110
|
+
function encryptJson(value: unknown, secret: string): string {
|
|
92
111
|
const iv = randomBytes(IV_LENGTH);
|
|
93
112
|
const cipher = createCipheriv("aes-256-gcm", deriveKey(secret), iv);
|
|
94
113
|
const ciphertext = Buffer.concat([
|
|
95
|
-
cipher.update(JSON.stringify(
|
|
114
|
+
cipher.update(JSON.stringify(value), "utf-8"),
|
|
96
115
|
cipher.final(),
|
|
97
116
|
]);
|
|
98
117
|
return Buffer.concat([iv, ciphertext, cipher.getAuthTag()]).toString(
|
|
@@ -100,11 +119,16 @@ function encryptPayload(
|
|
|
100
119
|
);
|
|
101
120
|
}
|
|
102
121
|
|
|
103
|
-
|
|
122
|
+
/**
|
|
123
|
+
* Payload-agnostic decrypt: decode base64url, AES-256-GCM decrypt, JSON.parse.
|
|
124
|
+
* Throws `ProviderCredentialDecryptError` on ANY failure or if the parsed
|
|
125
|
+
* result is not a non-null object. Per-kind validation lives in the caller.
|
|
126
|
+
*/
|
|
127
|
+
function decryptJson(
|
|
104
128
|
blob: string,
|
|
105
129
|
secret: string,
|
|
106
130
|
providerId: string,
|
|
107
|
-
):
|
|
131
|
+
): unknown {
|
|
108
132
|
let raw: Buffer;
|
|
109
133
|
try {
|
|
110
134
|
raw = Buffer.from(blob, "base64url");
|
|
@@ -119,7 +143,7 @@ function decryptPayload(
|
|
|
119
143
|
const ciphertext = raw.subarray(IV_LENGTH, raw.length - TAG_LENGTH);
|
|
120
144
|
const tag = raw.subarray(raw.length - TAG_LENGTH);
|
|
121
145
|
|
|
122
|
-
let
|
|
146
|
+
let value: unknown;
|
|
123
147
|
try {
|
|
124
148
|
const decipher = createDecipheriv("aes-256-gcm", deriveKey(secret), iv);
|
|
125
149
|
decipher.setAuthTag(tag);
|
|
@@ -127,11 +151,35 @@ function decryptPayload(
|
|
|
127
151
|
decipher.update(ciphertext),
|
|
128
152
|
decipher.final(),
|
|
129
153
|
]).toString("utf-8");
|
|
130
|
-
|
|
154
|
+
value = JSON.parse(plaintext);
|
|
131
155
|
} catch {
|
|
132
156
|
throw new ProviderCredentialDecryptError(providerId);
|
|
133
157
|
}
|
|
134
158
|
|
|
159
|
+
if (typeof value !== "object" || value === null) {
|
|
160
|
+
throw new ProviderCredentialDecryptError(providerId);
|
|
161
|
+
}
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function encryptPayload(
|
|
166
|
+
payload: OAuthCredentialPayload,
|
|
167
|
+
secret: string,
|
|
168
|
+
): string {
|
|
169
|
+
return encryptJson(payload, secret);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function decryptPayload(
|
|
173
|
+
blob: string,
|
|
174
|
+
secret: string,
|
|
175
|
+
providerId: string,
|
|
176
|
+
): OAuthCredentialPayload {
|
|
177
|
+
const payload = decryptJson(
|
|
178
|
+
blob,
|
|
179
|
+
secret,
|
|
180
|
+
providerId,
|
|
181
|
+
) as OAuthCredentialPayload;
|
|
182
|
+
|
|
135
183
|
if (
|
|
136
184
|
typeof payload.accessToken !== "string" ||
|
|
137
185
|
typeof payload.tokenEndpoint !== "string"
|
|
@@ -248,3 +296,54 @@ export async function deleteProviderCredential(
|
|
|
248
296
|
|
|
249
297
|
return deleted.length > 0;
|
|
250
298
|
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Read + decrypt the kind="derived" config for a provider. `null` when none
|
|
302
|
+
* stored; throws `ProviderCredentialDecryptError` when a row exists but cannot
|
|
303
|
+
* be decrypted. Unlike the oauth path there's no required-field validation —
|
|
304
|
+
* every field on `DerivedCredentialPayload` is optional.
|
|
305
|
+
*/
|
|
306
|
+
export async function getDerivedCredential(
|
|
307
|
+
db: Database,
|
|
308
|
+
providerId: string,
|
|
309
|
+
): Promise<DerivedCredentialPayload | null> {
|
|
310
|
+
const [row] = await db
|
|
311
|
+
.select()
|
|
312
|
+
.from(providerCredentials)
|
|
313
|
+
.where(
|
|
314
|
+
and(
|
|
315
|
+
eq(providerCredentials.providerId, providerId),
|
|
316
|
+
eq(providerCredentials.kind, "derived"),
|
|
317
|
+
),
|
|
318
|
+
)
|
|
319
|
+
.limit(1);
|
|
320
|
+
|
|
321
|
+
if (!row) return null;
|
|
322
|
+
|
|
323
|
+
return decryptJson(
|
|
324
|
+
row.payload,
|
|
325
|
+
env.BETTER_AUTH_SECRET,
|
|
326
|
+
providerId,
|
|
327
|
+
) as DerivedCredentialPayload;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Encrypt + UPSERT the kind="derived" config (full-payload overwrite, same dumb
|
|
332
|
+
* store as `saveProviderCredential`: merging old + new fields is the CALLER's
|
|
333
|
+
* job).
|
|
334
|
+
*/
|
|
335
|
+
export async function saveDerivedCredential(
|
|
336
|
+
db: Database,
|
|
337
|
+
providerId: string,
|
|
338
|
+
payload: DerivedCredentialPayload,
|
|
339
|
+
): Promise<void> {
|
|
340
|
+
const encrypted = encryptJson(payload, env.BETTER_AUTH_SECRET);
|
|
341
|
+
|
|
342
|
+
await db
|
|
343
|
+
.insert(providerCredentials)
|
|
344
|
+
.values({ providerId, kind: "derived", payload: encrypted })
|
|
345
|
+
.onConflictDoUpdate({
|
|
346
|
+
target: [providerCredentials.providerId, providerCredentials.kind],
|
|
347
|
+
set: { payload: encrypted, updatedAt: new Date() },
|
|
348
|
+
});
|
|
349
|
+
}
|
|
@@ -56,6 +56,12 @@ export interface ProvisionPostHogLoopResult {
|
|
|
56
56
|
projectId: string;
|
|
57
57
|
/** The URL the destination POSTs to: `${apiPublicUrl}/v1/webhooks/posthog`. */
|
|
58
58
|
webhookUrl: string;
|
|
59
|
+
/**
|
|
60
|
+
* The phc_ public capture key, read from the project object's `api_token`
|
|
61
|
+
* field. Grabbed opportunistically for the OPTIONAL outbound capture path;
|
|
62
|
+
* NOT needed for the inbound webhook loop. `undefined` if absent.
|
|
63
|
+
*/
|
|
64
|
+
projectApiKey?: string;
|
|
59
65
|
/** Best-effort deep link (pattern unverified — cosmetic, confirm in e2e). */
|
|
60
66
|
dashboardUrl: string;
|
|
61
67
|
}
|
|
@@ -269,10 +275,11 @@ export async function provisionPostHogLoop(
|
|
|
269
275
|
);
|
|
270
276
|
}
|
|
271
277
|
|
|
272
|
-
const projectId =
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
278
|
+
const { id: projectId, apiToken: projectApiKey } = await fetchProject({
|
|
279
|
+
privateHost,
|
|
280
|
+
accessToken,
|
|
281
|
+
projectId: opts.projectId,
|
|
282
|
+
});
|
|
276
283
|
|
|
277
284
|
const basePath = `/api/environments/${projectId}/hog_functions/`;
|
|
278
285
|
const desired: DesiredLoop = { webhookUrl, webhookSecret };
|
|
@@ -328,6 +335,7 @@ export async function provisionPostHogLoop(
|
|
|
328
335
|
functionId,
|
|
329
336
|
projectId,
|
|
330
337
|
webhookUrl,
|
|
338
|
+
projectApiKey,
|
|
331
339
|
dashboardUrl:
|
|
332
340
|
`${privateHost.replace(/\/+$/, "")}/project/${projectId}` +
|
|
333
341
|
`/pipeline/destinations/hog-${functionId}/configuration`,
|
|
@@ -335,21 +343,31 @@ export async function provisionPostHogLoop(
|
|
|
335
343
|
}
|
|
336
344
|
|
|
337
345
|
/**
|
|
338
|
-
* One-shot
|
|
339
|
-
*
|
|
346
|
+
* One-shot project fetch — ALWAYS runs (even when `opts.projectId` is
|
|
347
|
+
* env-set) so the project's `api_token` (the phc_ public capture key) is
|
|
348
|
+
* read on the way through. GETs `/api/projects/${projectId}/` when the id
|
|
349
|
+
* is known, else `/api/projects/@current/` for discovery. Returns both the
|
|
350
|
+
* resolved numeric id (stringified) and the optional `api_token`.
|
|
351
|
+
*
|
|
352
|
+
* Deliberately uncached and NOT shared with plugin-posthog's
|
|
340
353
|
* `resolveProjectId` — different process/cadence; provisioning is a
|
|
341
354
|
* one-shot admin action that tolerates a stray re-discovery.
|
|
342
355
|
*/
|
|
343
|
-
async function
|
|
356
|
+
async function fetchProject(opts: {
|
|
344
357
|
privateHost: string;
|
|
345
358
|
accessToken: string;
|
|
346
|
-
|
|
359
|
+
projectId?: string | number;
|
|
360
|
+
}): Promise<{ id: string; apiToken?: string }> {
|
|
361
|
+
const path =
|
|
362
|
+
opts.projectId !== undefined
|
|
363
|
+
? `/api/projects/${opts.projectId}/`
|
|
364
|
+
: "/api/projects/@current/";
|
|
347
365
|
let body: unknown;
|
|
348
366
|
try {
|
|
349
367
|
body = await phFetch({
|
|
350
368
|
privateHost: opts.privateHost,
|
|
351
369
|
accessToken: opts.accessToken,
|
|
352
|
-
path
|
|
370
|
+
path,
|
|
353
371
|
});
|
|
354
372
|
} catch (err) {
|
|
355
373
|
if (
|
|
@@ -370,11 +388,15 @@ async function discoverProjectId(opts: {
|
|
|
370
388
|
if (typeof id !== "number" && typeof id !== "string") {
|
|
371
389
|
throw new ProvisionPostHogLoopError({
|
|
372
390
|
code: "project-discovery-failed",
|
|
373
|
-
message:
|
|
391
|
+
message: `PostHog ${path} returned no project id.`,
|
|
374
392
|
remediation: PROJECT_DISCOVERY_REMEDIATION,
|
|
375
393
|
});
|
|
376
394
|
}
|
|
377
|
-
|
|
395
|
+
const apiToken =
|
|
396
|
+
isRecord(body) && typeof body.api_token === "string"
|
|
397
|
+
? body.api_token
|
|
398
|
+
: undefined;
|
|
399
|
+
return { id: String(id), apiToken };
|
|
378
400
|
}
|
|
379
401
|
|
|
380
402
|
/** Bearer + JSON fetch with the documented error mapping. */
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
1
2
|
import { DEFAULT_HOST, derivePrivateHost } from "@hogsend/plugin-posthog";
|
|
2
3
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
4
|
import type { AppEnv } from "../../app.js";
|
|
4
5
|
import { createTokenManager } from "../../lib/oauth-token-manager.js";
|
|
6
|
+
import { EXPECTED_POSTHOG_SCOPES } from "../../lib/posthog-scopes.js";
|
|
7
|
+
import {
|
|
8
|
+
getDerivedCredential,
|
|
9
|
+
getProviderCredential,
|
|
10
|
+
saveDerivedCredential,
|
|
11
|
+
} from "../../lib/provider-credentials.js";
|
|
5
12
|
import {
|
|
6
13
|
ProvisionPostHogLoopError,
|
|
7
14
|
provisionPostHogLoop,
|
|
@@ -30,8 +37,16 @@ export const connectInfoSchema = z.object({
|
|
|
30
37
|
personalKeyConfigured: z.boolean(),
|
|
31
38
|
webhookSecretConfigured: z.boolean(),
|
|
32
39
|
apiPublicUrl: z.string(),
|
|
40
|
+
/**
|
|
41
|
+
* Expected PostHog OAuth scopes the stored credential is MISSING (the
|
|
42
|
+
* CLI surfaces these so the user can reconnect for the broader grant).
|
|
43
|
+
* `[]` when nothing is stored or the stored grant already covers them.
|
|
44
|
+
* Computed in the handler — NOT part of the pure `resolveConnectInfo`
|
|
45
|
+
* env projection (which `ConnectInfo` mirrors), so it omits this key.
|
|
46
|
+
*/
|
|
47
|
+
scopeGap: z.array(z.string()),
|
|
33
48
|
});
|
|
34
|
-
export type ConnectInfo = z.infer<typeof connectInfoSchema>;
|
|
49
|
+
export type ConnectInfo = Omit<z.infer<typeof connectInfoSchema>, "scopeGap">;
|
|
35
50
|
|
|
36
51
|
/**
|
|
37
52
|
* True when a public URL points at a loopback/unspecified address — PostHog
|
|
@@ -169,8 +184,35 @@ const provisionLoopRoute = createRoute({
|
|
|
169
184
|
});
|
|
170
185
|
|
|
171
186
|
export const analyticsAdminRouter = new OpenAPIHono<AppEnv>()
|
|
172
|
-
.openapi(connectInfoRoute, (c) => {
|
|
173
|
-
|
|
187
|
+
.openapi(connectInfoRoute, async (c) => {
|
|
188
|
+
const { db, env } = c.get("container");
|
|
189
|
+
|
|
190
|
+
// The env projection is the pure base. The store can ALSO hold a minted
|
|
191
|
+
// webhook secret (provision-loop mints + persists one when env lacks it),
|
|
192
|
+
// so the configured-ness flag OR-s the two sources of truth.
|
|
193
|
+
const storedDerived = await getDerivedCredential(db, "posthog");
|
|
194
|
+
const webhookSecretConfigured =
|
|
195
|
+
Boolean(env.POSTHOG_WEBHOOK_SECRET) ||
|
|
196
|
+
Boolean(storedDerived?.webhookSecret);
|
|
197
|
+
|
|
198
|
+
// scopeGap = expected scopes the STORED oauth credential is missing. No
|
|
199
|
+
// credential ⇒ no gap to report (the connect flow will request the full
|
|
200
|
+
// set). A decrypt failure is non-fatal here — fall back to no gap.
|
|
201
|
+
let scopeGap: string[] = [];
|
|
202
|
+
try {
|
|
203
|
+
const oauth = await getProviderCredential(db, "posthog");
|
|
204
|
+
if (oauth) {
|
|
205
|
+
const granted = oauth.payload.scopes;
|
|
206
|
+
scopeGap = EXPECTED_POSTHOG_SCOPES.filter((s) => !granted.includes(s));
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
scopeGap = [];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return c.json(
|
|
213
|
+
{ ...resolveConnectInfo(env), webhookSecretConfigured, scopeGap },
|
|
214
|
+
200,
|
|
215
|
+
);
|
|
174
216
|
})
|
|
175
217
|
.openapi(provisionLoopRoute, async (c) => {
|
|
176
218
|
const { db, env, logger } = c.get("container");
|
|
@@ -215,6 +257,24 @@ export const analyticsAdminRouter = new OpenAPIHono<AppEnv>()
|
|
|
215
257
|
);
|
|
216
258
|
}
|
|
217
259
|
|
|
260
|
+
// Resolve the webhook secret instead of requiring it: env wins, else a
|
|
261
|
+
// previously-minted stored secret, else mint a fresh one. When env has no
|
|
262
|
+
// secret, persist the resolved value so it survives AND the inbound
|
|
263
|
+
// posthog webhook source can resolve it from the store at request time
|
|
264
|
+
// (the source falls OPEN otherwise). The stored payload is merged so an
|
|
265
|
+
// existing phc_/projectId is preserved.
|
|
266
|
+
const storedDerived = await getDerivedCredential(db, "posthog");
|
|
267
|
+
const webhookSecret =
|
|
268
|
+
env.POSTHOG_WEBHOOK_SECRET ??
|
|
269
|
+
storedDerived?.webhookSecret ??
|
|
270
|
+
randomBytes(32).toString("hex");
|
|
271
|
+
if (env.POSTHOG_WEBHOOK_SECRET === undefined) {
|
|
272
|
+
await saveDerivedCredential(db, "posthog", {
|
|
273
|
+
...(storedDerived ?? {}),
|
|
274
|
+
webhookSecret,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
218
278
|
try {
|
|
219
279
|
const result = await provisionPostHogLoop({
|
|
220
280
|
privateHost: info.privateHost,
|
|
@@ -225,10 +285,24 @@ export const analyticsAdminRouter = new OpenAPIHono<AppEnv>()
|
|
|
225
285
|
? { projectId: env.POSTHOG_PROJECT_ID }
|
|
226
286
|
: {}),
|
|
227
287
|
apiPublicUrl: info.apiPublicUrl,
|
|
228
|
-
webhookSecret
|
|
288
|
+
webhookSecret,
|
|
229
289
|
logger,
|
|
230
290
|
});
|
|
231
291
|
|
|
292
|
+
// Opportunistically persist the phc_ (project api_token) the provisioner
|
|
293
|
+
// read on its way through — it powers the OPTIONAL outbound capture path
|
|
294
|
+
// and activates on the next deploy (no lazy boot-time seam). Re-read the
|
|
295
|
+
// stored payload to merge over the just-persisted webhook secret.
|
|
296
|
+
if (result.projectApiKey) {
|
|
297
|
+
const cur = (await getDerivedCredential(db, "posthog")) ?? {};
|
|
298
|
+
await saveDerivedCredential(db, "posthog", {
|
|
299
|
+
...cur,
|
|
300
|
+
projectApiKey: result.projectApiKey,
|
|
301
|
+
projectId: result.projectId,
|
|
302
|
+
privateHost: info.privateHost,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
232
306
|
// M4 translation: the CLI's documented shape, with `action` riding
|
|
233
307
|
// along for --json consumers.
|
|
234
308
|
return c.json(
|
|
@@ -127,9 +127,13 @@ const deleteRoute = createRoute({
|
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
function serializeMeta(meta: ProviderCredentialMeta) {
|
|
130
|
+
// This admin surface is OAuth-only: PUT forces `kind: "oauth"` and GET reads
|
|
131
|
+
// the oauth credential, so the meta's kind is always "oauth" at runtime even
|
|
132
|
+
// though `ProviderCredentialMeta.kind` widened to the "oauth" | "derived"
|
|
133
|
+
// union when the derived store landed. Narrow it back to the schema literal.
|
|
130
134
|
return {
|
|
131
135
|
providerId: meta.providerId,
|
|
132
|
-
kind:
|
|
136
|
+
kind: "oauth" as const,
|
|
133
137
|
scopes: meta.scopes,
|
|
134
138
|
expiresAt: meta.expiresAt.toISOString(),
|
|
135
139
|
scopedTeams: meta.scopedTeams,
|
|
@@ -1,10 +1,57 @@
|
|
|
1
|
+
import type { Database } from "@hogsend/db";
|
|
1
2
|
import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi";
|
|
2
3
|
import type { AppEnv } from "../../app.js";
|
|
3
4
|
import { headersToRecord } from "../../lib/headers.js";
|
|
4
5
|
import { ingestEvent } from "../../lib/ingestion.js";
|
|
6
|
+
import type { Logger } from "../../lib/logger.js";
|
|
7
|
+
import { getDerivedCredential } from "../../lib/provider-credentials.js";
|
|
5
8
|
import type { DefinedWebhookSource } from "../../webhook-sources/define-webhook-source.js";
|
|
6
9
|
import { verifySignature } from "../../webhook-sources/verify.js";
|
|
7
10
|
|
|
11
|
+
/** Negative-cache window for the stored PostHog secret (mirrors the token
|
|
12
|
+
* manager's ABSENT_RECHECK_MS) — caches present AND absent results so an
|
|
13
|
+
* inbound PostHog webhook POST does not hit the DB on every request. */
|
|
14
|
+
const STORED_SECRET_RECHECK_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
let storedPosthogSecret: { value: string | undefined; at: number } | undefined;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The minted PostHog webhook secret falls back to the `kind="derived"` store
|
|
20
|
+
* when `POSTHOG_WEBHOOK_SECRET` is unset — so an inbound event verifies WITHOUT
|
|
21
|
+
* a redeploy after `hogsend connect posthog`. Cached (present and absent) for
|
|
22
|
+
* `STORED_SECRET_RECHECK_MS` to keep the hot webhook path off the DB.
|
|
23
|
+
*
|
|
24
|
+
* A store read failure (e.g. DB blip, or a derived row that no longer decrypts)
|
|
25
|
+
* resolves to `undefined` rather than throwing — the inbound webhook path must
|
|
26
|
+
* not 500 on a degraded store. `undefined` keeps match-auth in its pre-feature
|
|
27
|
+
* posture (OPEN when no secret is configured anywhere), and the failure is
|
|
28
|
+
* logged so the misconfiguration is still visible.
|
|
29
|
+
*/
|
|
30
|
+
async function resolveStoredPosthogSecret(
|
|
31
|
+
db: Database,
|
|
32
|
+
logger: Logger,
|
|
33
|
+
): Promise<string | undefined> {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
if (
|
|
36
|
+
storedPosthogSecret &&
|
|
37
|
+
now - storedPosthogSecret.at <= STORED_SECRET_RECHECK_MS
|
|
38
|
+
) {
|
|
39
|
+
return storedPosthogSecret.value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let value: string | undefined;
|
|
43
|
+
try {
|
|
44
|
+
value = (await getDerivedCredential(db, "posthog"))?.webhookSecret;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
logger.warn("Failed to resolve stored PostHog webhook secret", {
|
|
47
|
+
error: err instanceof Error ? err.message : String(err),
|
|
48
|
+
});
|
|
49
|
+
value = undefined;
|
|
50
|
+
}
|
|
51
|
+
storedPosthogSecret = { value, at: now };
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
8
55
|
export function registerWebhookSourceRoutes(
|
|
9
56
|
app: OpenAPIHono<AppEnv>,
|
|
10
57
|
sources: DefinedWebhookSource[],
|
|
@@ -72,10 +119,22 @@ export function registerWebhookSourceRoutes(
|
|
|
72
119
|
const rawBody = await c.req.text();
|
|
73
120
|
const headers = headersToRecord(c.req.raw.headers);
|
|
74
121
|
|
|
75
|
-
|
|
122
|
+
let secret = env[source.auth.envKey as keyof typeof env] as
|
|
76
123
|
| string
|
|
77
124
|
| undefined;
|
|
78
125
|
|
|
126
|
+
// For the inbound PostHog source, fall back to the secret minted by
|
|
127
|
+
// `hogsend connect` (kind="derived" store) when env has none — so an
|
|
128
|
+
// inbound event verifies WITHOUT a redeploy. Leaves match-auth OPEN when
|
|
129
|
+
// neither env nor the store has a secret (current behavior preserved).
|
|
130
|
+
if (
|
|
131
|
+
!secret &&
|
|
132
|
+
source.auth.type === "match" &&
|
|
133
|
+
source.auth.envKey === "POSTHOG_WEBHOOK_SECRET"
|
|
134
|
+
) {
|
|
135
|
+
secret = await resolveStoredPosthogSecret(db, logger);
|
|
136
|
+
}
|
|
137
|
+
|
|
79
138
|
if (source.auth.type === "signature") {
|
|
80
139
|
// Signature sources FAIL CLOSED: an unset secret is a 401, never an open
|
|
81
140
|
// pass-through (deliberate divergence from the "match" variant).
|