@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.20.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.20.0",
44
- "@hogsend/db": "^0.20.0",
45
- "@hogsend/email": "^0.20.0",
46
- "@hogsend/plugin-posthog": "^0.20.0",
47
- "@hogsend/plugin-resend": "^0.20.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.20.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
- /** Only "oauth" today; widen the union when an "api_key" kind lands. */
20
- export type CredentialKind = "oauth";
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
- function encryptPayload(
89
- payload: OAuthCredentialPayload,
90
- secret: string,
91
- ): string {
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(payload), "utf-8"),
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
- function decryptPayload(
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
- ): OAuthCredentialPayload {
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 payload: OAuthCredentialPayload;
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
- payload = JSON.parse(plaintext);
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
- opts.projectId !== undefined
274
- ? String(opts.projectId)
275
- : await discoverProjectId({ privateHost, accessToken });
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 `@current` discovery, used only when `opts.projectId` is not
339
- * given. Deliberately uncached and NOT shared with plugin-posthog's
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 discoverProjectId(opts: {
356
+ async function fetchProject(opts: {
344
357
  privateHost: string;
345
358
  accessToken: string;
346
- }): Promise<string> {
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: "/api/projects/@current/",
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: "PostHog /api/projects/@current/ returned no project id.",
391
+ message: `PostHog ${path} returned no project id.`,
374
392
  remediation: PROJECT_DISCOVERY_REMEDIATION,
375
393
  });
376
394
  }
377
- return String(id);
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
- return c.json(resolveConnectInfo(c.get("container").env), 200);
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: env.POSTHOG_WEBHOOK_SECRET,
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: meta.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
- const secret = env[source.auth.envKey as keyof typeof env] as
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).