@hogsend/engine 0.20.0 → 0.21.1
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 +129 -11
- package/src/lib/provision-posthog-loop.ts +33 -11
- package/src/routes/admin/analytics.ts +84 -10
- package/src/routes/admin/provider-credentials.ts +20 -7
- package/src/routes/webhooks/sources.ts +70 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
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.1",
|
|
44
|
+
"@hogsend/db": "^0.21.1",
|
|
45
|
+
"@hogsend/email": "^0.21.1",
|
|
46
|
+
"@hogsend/plugin-posthog": "^0.21.1",
|
|
47
|
+
"@hogsend/plugin-resend": "^0.21.1"
|
|
48
48
|
},
|
|
49
49
|
"optionalDependencies": {
|
|
50
|
-
"@hogsend/plugin-postmark": "^0.
|
|
50
|
+
"@hogsend/plugin-postmark": "^0.21.1"
|
|
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,73 @@ export async function deleteProviderCredential(
|
|
|
248
296
|
|
|
249
297
|
return deleted.length > 0;
|
|
250
298
|
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Purge EVERY stored credential for a provider — the oauth grant AND the
|
|
302
|
+
* server-derived config (minted webhook secret + grabbed phc_). Disconnect
|
|
303
|
+
* must leave no orphaned rows; the single-kind `deleteProviderCredential`
|
|
304
|
+
* stays unchanged so token-lifecycle callers can still target one kind.
|
|
305
|
+
* Returns which kinds were removed. Never decrypts.
|
|
306
|
+
*/
|
|
307
|
+
export async function deleteAllProviderCredentials(
|
|
308
|
+
db: Database,
|
|
309
|
+
providerId: string,
|
|
310
|
+
): Promise<{ oauth: boolean; derived: boolean }> {
|
|
311
|
+
const [oauth, derived] = await Promise.all([
|
|
312
|
+
deleteProviderCredential(db, providerId, "oauth"),
|
|
313
|
+
deleteProviderCredential(db, providerId, "derived"),
|
|
314
|
+
]);
|
|
315
|
+
|
|
316
|
+
return { oauth, derived };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Read + decrypt the kind="derived" config for a provider. `null` when none
|
|
321
|
+
* stored; throws `ProviderCredentialDecryptError` when a row exists but cannot
|
|
322
|
+
* be decrypted. Unlike the oauth path there's no required-field validation —
|
|
323
|
+
* every field on `DerivedCredentialPayload` is optional.
|
|
324
|
+
*/
|
|
325
|
+
export async function getDerivedCredential(
|
|
326
|
+
db: Database,
|
|
327
|
+
providerId: string,
|
|
328
|
+
): Promise<DerivedCredentialPayload | null> {
|
|
329
|
+
const [row] = await db
|
|
330
|
+
.select()
|
|
331
|
+
.from(providerCredentials)
|
|
332
|
+
.where(
|
|
333
|
+
and(
|
|
334
|
+
eq(providerCredentials.providerId, providerId),
|
|
335
|
+
eq(providerCredentials.kind, "derived"),
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
.limit(1);
|
|
339
|
+
|
|
340
|
+
if (!row) return null;
|
|
341
|
+
|
|
342
|
+
return decryptJson(
|
|
343
|
+
row.payload,
|
|
344
|
+
env.BETTER_AUTH_SECRET,
|
|
345
|
+
providerId,
|
|
346
|
+
) as DerivedCredentialPayload;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Encrypt + UPSERT the kind="derived" config (full-payload overwrite, same dumb
|
|
351
|
+
* store as `saveProviderCredential`: merging old + new fields is the CALLER's
|
|
352
|
+
* job).
|
|
353
|
+
*/
|
|
354
|
+
export async function saveDerivedCredential(
|
|
355
|
+
db: Database,
|
|
356
|
+
providerId: string,
|
|
357
|
+
payload: DerivedCredentialPayload,
|
|
358
|
+
): Promise<void> {
|
|
359
|
+
const encrypted = encryptJson(payload, env.BETTER_AUTH_SECRET);
|
|
360
|
+
|
|
361
|
+
await db
|
|
362
|
+
.insert(providerCredentials)
|
|
363
|
+
.values({ providerId, kind: "derived", payload: encrypted })
|
|
364
|
+
.onConflictDoUpdate({
|
|
365
|
+
target: [providerCredentials.providerId, providerCredentials.kind],
|
|
366
|
+
set: { payload: encrypted, updatedAt: new Date() },
|
|
367
|
+
});
|
|
368
|
+
}
|
|
@@ -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,12 +1,20 @@
|
|
|
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,
|
|
8
15
|
} from "../../lib/provision-posthog-loop.js";
|
|
9
16
|
import { errorSchema } from "../../lib/schemas.js";
|
|
17
|
+
import { invalidateStoredPosthogSecret } from "../webhooks/sources.js";
|
|
10
18
|
|
|
11
19
|
/**
|
|
12
20
|
* Admin analytics-connection routes — the server half of
|
|
@@ -30,8 +38,16 @@ export const connectInfoSchema = z.object({
|
|
|
30
38
|
personalKeyConfigured: z.boolean(),
|
|
31
39
|
webhookSecretConfigured: z.boolean(),
|
|
32
40
|
apiPublicUrl: z.string(),
|
|
41
|
+
/**
|
|
42
|
+
* Expected PostHog OAuth scopes the stored credential is MISSING (the
|
|
43
|
+
* CLI surfaces these so the user can reconnect for the broader grant).
|
|
44
|
+
* `[]` when nothing is stored or the stored grant already covers them.
|
|
45
|
+
* Computed in the handler — NOT part of the pure `resolveConnectInfo`
|
|
46
|
+
* env projection (which `ConnectInfo` mirrors), so it omits this key.
|
|
47
|
+
*/
|
|
48
|
+
scopeGap: z.array(z.string()),
|
|
33
49
|
});
|
|
34
|
-
export type ConnectInfo = z.infer<typeof connectInfoSchema>;
|
|
50
|
+
export type ConnectInfo = Omit<z.infer<typeof connectInfoSchema>, "scopeGap">;
|
|
35
51
|
|
|
36
52
|
/**
|
|
37
53
|
* True when a public URL points at a loopback/unspecified address — PostHog
|
|
@@ -155,9 +171,8 @@ const provisionLoopRoute = createRoute({
|
|
|
155
171
|
description:
|
|
156
172
|
"Refused: `no_posthog_credential` (no OAuth credential and no " +
|
|
157
173
|
"personal API key), `posthog_not_configured` (no PostHog env signal " +
|
|
158
|
-
"at all), `
|
|
159
|
-
"
|
|
160
|
-
"PostHog cannot deliver to it)",
|
|
174
|
+
"at all), or `api_public_url_unreachable` (API_PUBLIC_URL is loopback " +
|
|
175
|
+
"— PostHog cannot deliver to it)",
|
|
161
176
|
},
|
|
162
177
|
502: {
|
|
163
178
|
content: { "application/json": { schema: provisionFailureSchema } },
|
|
@@ -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,27 @@ 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
|
+
// Bust the inbound posthog webhook source's cached secret so it enforces
|
|
277
|
+
// the freshly-minted value immediately instead of after the ~30s TTL.
|
|
278
|
+
invalidateStoredPosthogSecret();
|
|
279
|
+
}
|
|
280
|
+
|
|
218
281
|
try {
|
|
219
282
|
const result = await provisionPostHogLoop({
|
|
220
283
|
privateHost: info.privateHost,
|
|
@@ -225,10 +288,24 @@ export const analyticsAdminRouter = new OpenAPIHono<AppEnv>()
|
|
|
225
288
|
? { projectId: env.POSTHOG_PROJECT_ID }
|
|
226
289
|
: {}),
|
|
227
290
|
apiPublicUrl: info.apiPublicUrl,
|
|
228
|
-
webhookSecret
|
|
291
|
+
webhookSecret,
|
|
229
292
|
logger,
|
|
230
293
|
});
|
|
231
294
|
|
|
295
|
+
// Opportunistically persist the phc_ (project api_token) the provisioner
|
|
296
|
+
// read on its way through — it powers the OPTIONAL outbound capture path
|
|
297
|
+
// and activates on the next deploy (no lazy boot-time seam). Re-read the
|
|
298
|
+
// stored payload to merge over the just-persisted webhook secret.
|
|
299
|
+
if (result.projectApiKey) {
|
|
300
|
+
const cur = (await getDerivedCredential(db, "posthog")) ?? {};
|
|
301
|
+
await saveDerivedCredential(db, "posthog", {
|
|
302
|
+
...cur,
|
|
303
|
+
projectApiKey: result.projectApiKey,
|
|
304
|
+
projectId: result.projectId,
|
|
305
|
+
privateHost: info.privateHost,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
232
309
|
// M4 translation: the CLI's documented shape, with `action` riding
|
|
233
310
|
// along for --json consumers.
|
|
234
311
|
return c.json(
|
|
@@ -244,9 +321,6 @@ export const analyticsAdminRouter = new OpenAPIHono<AppEnv>()
|
|
|
244
321
|
);
|
|
245
322
|
} catch (error) {
|
|
246
323
|
if (error instanceof ProvisionPostHogLoopError) {
|
|
247
|
-
if (error.code === "missing-webhook-secret") {
|
|
248
|
-
return c.json({ error: "webhook_secret_missing" }, 409);
|
|
249
|
-
}
|
|
250
324
|
return c.json(
|
|
251
325
|
{
|
|
252
326
|
error: error.code,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
2
2
|
import type { AppEnv } from "../../app.js";
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
deleteAllProviderCredentials,
|
|
5
5
|
getProviderCredential,
|
|
6
6
|
ProviderCredentialDecryptError,
|
|
7
7
|
type ProviderCredentialMeta,
|
|
@@ -108,7 +108,11 @@ const deleteRoute = createRoute({
|
|
|
108
108
|
method: "delete",
|
|
109
109
|
path: "/{providerId}",
|
|
110
110
|
tags: ["Admin — Provider Credentials"],
|
|
111
|
-
summary: "
|
|
111
|
+
summary: "Purge a provider's stored credentials (oauth + derived)",
|
|
112
|
+
description:
|
|
113
|
+
"Disconnect: hard-deletes EVERY stored credential for the provider — " +
|
|
114
|
+
"the oauth grant AND the server-derived config (minted webhook secret + " +
|
|
115
|
+
"grabbed phc_) — so no orphaned rows remain.",
|
|
112
116
|
request: {
|
|
113
117
|
params: providerIdParam,
|
|
114
118
|
},
|
|
@@ -117,19 +121,23 @@ const deleteRoute = createRoute({
|
|
|
117
121
|
content: {
|
|
118
122
|
"application/json": { schema: z.object({ deleted: z.boolean() }) },
|
|
119
123
|
},
|
|
120
|
-
description: "
|
|
124
|
+
description: "Credentials hard-deleted (at least one row removed)",
|
|
121
125
|
},
|
|
122
126
|
404: {
|
|
123
127
|
content: { "application/json": { schema: errorSchema } },
|
|
124
|
-
description: "No
|
|
128
|
+
description: "No credentials stored for this provider",
|
|
125
129
|
},
|
|
126
130
|
},
|
|
127
131
|
});
|
|
128
132
|
|
|
129
133
|
function serializeMeta(meta: ProviderCredentialMeta) {
|
|
134
|
+
// This admin surface is OAuth-only: PUT forces `kind: "oauth"` and GET reads
|
|
135
|
+
// the oauth credential, so the meta's kind is always "oauth" at runtime even
|
|
136
|
+
// though `ProviderCredentialMeta.kind` widened to the "oauth" | "derived"
|
|
137
|
+
// union when the derived store landed. Narrow it back to the schema literal.
|
|
130
138
|
return {
|
|
131
139
|
providerId: meta.providerId,
|
|
132
|
-
kind:
|
|
140
|
+
kind: "oauth" as const,
|
|
133
141
|
scopes: meta.scopes,
|
|
134
142
|
expiresAt: meta.expiresAt.toISOString(),
|
|
135
143
|
scopedTeams: meta.scopedTeams,
|
|
@@ -176,8 +184,13 @@ export const providerCredentialsRouter = new OpenAPIHono<AppEnv>()
|
|
|
176
184
|
|
|
177
185
|
// Never decrypts — DELETE must succeed even when the payload is
|
|
178
186
|
// undecryptable (the operator's escape hatch after a secret rotation).
|
|
179
|
-
|
|
180
|
-
|
|
187
|
+
// Disconnect purges BOTH kinds (oauth grant + derived config) so the
|
|
188
|
+
// minted webhook secret + grabbed phc_ never linger orphaned.
|
|
189
|
+
const { oauth, derived } = await deleteAllProviderCredentials(
|
|
190
|
+
db,
|
|
191
|
+
providerId,
|
|
192
|
+
);
|
|
193
|
+
if (!oauth && !derived) {
|
|
181
194
|
return c.json({ error: "Provider credential not found" }, 404);
|
|
182
195
|
}
|
|
183
196
|
return c.json({ deleted: true }, 200);
|
|
@@ -1,10 +1,67 @@
|
|
|
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
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Drop the module-level stored-secret cache so the next inbound PostHog webhook
|
|
57
|
+
* re-reads from the `kind="derived"` store. Called right after `hogsend connect`
|
|
58
|
+
* mints + persists a secret, so the freshly-minted value is enforced
|
|
59
|
+
* immediately instead of waiting out the `STORED_SECRET_RECHECK_MS` window.
|
|
60
|
+
*/
|
|
61
|
+
export function invalidateStoredPosthogSecret(): void {
|
|
62
|
+
storedPosthogSecret = undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
8
65
|
export function registerWebhookSourceRoutes(
|
|
9
66
|
app: OpenAPIHono<AppEnv>,
|
|
10
67
|
sources: DefinedWebhookSource[],
|
|
@@ -72,10 +129,22 @@ export function registerWebhookSourceRoutes(
|
|
|
72
129
|
const rawBody = await c.req.text();
|
|
73
130
|
const headers = headersToRecord(c.req.raw.headers);
|
|
74
131
|
|
|
75
|
-
|
|
132
|
+
let secret = env[source.auth.envKey as keyof typeof env] as
|
|
76
133
|
| string
|
|
77
134
|
| undefined;
|
|
78
135
|
|
|
136
|
+
// For the inbound PostHog source, fall back to the secret minted by
|
|
137
|
+
// `hogsend connect` (kind="derived" store) when env has none — so an
|
|
138
|
+
// inbound event verifies WITHOUT a redeploy. Leaves match-auth OPEN when
|
|
139
|
+
// neither env nor the store has a secret (current behavior preserved).
|
|
140
|
+
if (
|
|
141
|
+
!secret &&
|
|
142
|
+
source.auth.type === "match" &&
|
|
143
|
+
source.auth.envKey === "POSTHOG_WEBHOOK_SECRET"
|
|
144
|
+
) {
|
|
145
|
+
secret = await resolveStoredPosthogSecret(db, logger);
|
|
146
|
+
}
|
|
147
|
+
|
|
79
148
|
if (source.auth.type === "signature") {
|
|
80
149
|
// Signature sources FAIL CLOSED: an unset secret is a 401, never an open
|
|
81
150
|
// pass-through (deliberate divergence from the "match" variant).
|