@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.20.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.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.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.20.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
- /** 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,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
- 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,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), `webhook_secret_missing` (POSTHOG_WEBHOOK_SECRET unset), " +
159
- "or `api_public_url_unreachable` (API_PUBLIC_URL is loopback " +
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
- 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,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: env.POSTHOG_WEBHOOK_SECRET,
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
- deleteProviderCredential,
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: "Delete a provider credential",
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: "Credential hard-deleted",
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 credential stored for this provider",
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: meta.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
- const deleted = await deleteProviderCredential(db, providerId);
180
- if (!deleted) {
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
- const secret = env[source.auth.envKey as keyof typeof env] as
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).