@hogsend/engine 0.19.0 → 0.20.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.
@@ -23,6 +23,19 @@ const POSTHOG_FUNNEL_EVENTS = [
23
23
  "email.complained",
24
24
  ] as const;
25
25
 
26
+ /**
27
+ * Person-property propagation events (the contact → analytics-person rail).
28
+ * The preset's `syncPersons` flag turns these into `$set` captures of the
29
+ * contact's properties under its canonical key; without the flag the
30
+ * transform SKIPS them, so subscribing them is only meaningful together
31
+ * with `config.syncPersons: true`.
32
+ */
33
+ const POSTHOG_PERSON_SYNC_EVENTS = [
34
+ "contact.created",
35
+ "contact.updated",
36
+ "contact.unsubscribed",
37
+ ] as const;
38
+
26
39
  /**
27
40
  * Idempotently seed ONE `kind="posthog"` webhook endpoint subscribed to the
28
41
  * email funnel, so the full email lifecycle fans out to PostHog DURABLY on the
@@ -56,6 +69,7 @@ export async function seedPostHogDestination(opts: {
56
69
  id: webhookEndpoints.id,
57
70
  url: webhookEndpoints.url,
58
71
  eventTypes: webhookEndpoints.eventTypes,
72
+ config: webhookEndpoints.config,
59
73
  })
60
74
  .from(webhookEndpoints)
61
75
  .where(
@@ -78,19 +92,31 @@ export async function seedPostHogDestination(opts: {
78
92
  const current = Array.isArray(found.eventTypes)
79
93
  ? (found.eventTypes as string[])
80
94
  : [];
81
- const missing = POSTHOG_FUNNEL_EVENTS.filter(
82
- (e) => !current.includes(e),
83
- );
84
- if (missing.length > 0) {
95
+ const missing = [
96
+ ...POSTHOG_FUNNEL_EVENTS,
97
+ ...POSTHOG_PERSON_SYNC_EVENTS,
98
+ ].filter((e) => !current.includes(e));
99
+ // Person-sync default-on for the ENGINE-seeded row, but never override
100
+ // an explicit operator choice: only set the flag when it is ABSENT
101
+ // from config (an explicit `false` stays false).
102
+ const existingConfig = (found.config ?? {}) as Record<string, unknown>;
103
+ const needsSyncFlag = existingConfig.syncPersons === undefined;
104
+ if (missing.length > 0 || needsSyncFlag) {
85
105
  await tx
86
106
  .update(webhookEndpoints)
87
107
  .set({
88
- eventTypes: [...current, ...missing],
108
+ ...(missing.length > 0
109
+ ? { eventTypes: [...current, ...missing] }
110
+ : {}),
111
+ ...(needsSyncFlag
112
+ ? { config: { ...existingConfig, syncPersons: true } }
113
+ : {}),
89
114
  updatedAt: new Date(),
90
115
  })
91
116
  .where(eq(webhookEndpoints.id, found.id));
92
- logger.info("Reconciled seeded PostHog destination event types", {
117
+ logger.info("Reconciled seeded PostHog destination", {
93
118
  added: missing,
119
+ syncPersonsEnabled: needsSyncFlag,
94
120
  });
95
121
  }
96
122
  }
@@ -110,8 +136,13 @@ export async function seedPostHogDestination(opts: {
110
136
  // remaps the canonical "email.clicked" back so existing PostHog funnels
111
137
  // keep working after the cutover.
112
138
  eventNames: { "email.clicked": "email.link_clicked" },
139
+ // Person-property propagation ON for the seeded destination: contact
140
+ // truth ($set of contact.properties) lands on the same PostHog person
141
+ // the identify loop addresses. Disable per-endpoint via the admin API
142
+ // (config.syncPersons: false).
143
+ syncPersons: true,
113
144
  },
114
- eventTypes: [...POSTHOG_FUNNEL_EVENTS],
145
+ eventTypes: [...POSTHOG_FUNNEL_EVENTS, ...POSTHOG_PERSON_SYNC_EVENTS],
115
146
  secret: null,
116
147
  secretPrefix: null,
117
148
  disabled: false,
@@ -0,0 +1,261 @@
1
+ import { DEFAULT_HOST, derivePrivateHost } from "@hogsend/plugin-posthog";
2
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
+ import type { AppEnv } from "../../app.js";
4
+ import { createTokenManager } from "../../lib/oauth-token-manager.js";
5
+ import {
6
+ ProvisionPostHogLoopError,
7
+ provisionPostHogLoop,
8
+ } from "../../lib/provision-posthog-loop.js";
9
+ import { errorSchema } from "../../lib/schemas.js";
10
+
11
+ /**
12
+ * Admin analytics-connection routes — the server half of
13
+ * `hogsend connect posthog`. Mounted at `/v1/admin/analytics`, inheriting
14
+ * `requireAdmin` + `rateLimit` + `auditMiddleware` from the admin router root.
15
+ *
16
+ * - `GET /connect-info` surfaces the instance's PostHog env signal so the CLI
17
+ * needs NO PostHog env vars locally (it learns the region + readiness from
18
+ * the server). Pure env projection — it never discovers anything.
19
+ * - `POST /provision-loop` runs the idempotent PostHog → Hogsend hog-function
20
+ * provisioner server-side with the server's own credential (OAuth credential
21
+ * via a route-local token manager, falling back to the personal API key).
22
+ */
23
+
24
+ export const connectInfoSchema = z.object({
25
+ providerId: z.literal("posthog"),
26
+ analyticsConfigured: z.boolean(),
27
+ privateHost: z.string().nullable(),
28
+ hostExplicit: z.boolean(),
29
+ projectIdHint: z.string().nullable(),
30
+ personalKeyConfigured: z.boolean(),
31
+ webhookSecretConfigured: z.boolean(),
32
+ apiPublicUrl: z.string(),
33
+ });
34
+ export type ConnectInfo = z.infer<typeof connectInfoSchema>;
35
+
36
+ /**
37
+ * True when a public URL points at a loopback/unspecified address — PostHog
38
+ * Cloud can never deliver to it, so provisioning must refuse rather than
39
+ * create (or repoint!) a destination nobody can reach.
40
+ */
41
+ export function isLoopbackPublicUrl(publicUrl: string): boolean {
42
+ try {
43
+ const host = new URL(publicUrl).hostname.toLowerCase();
44
+ return (
45
+ host === "localhost" ||
46
+ host === "127.0.0.1" ||
47
+ host === "0.0.0.0" ||
48
+ host === "[::1]" ||
49
+ host === "::1" ||
50
+ host.endsWith(".localhost")
51
+ );
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Pure env projection, exported for unit tests — no app boot needed.
59
+ *
60
+ * `privateHost: null` only when the server has NO PostHog signal at all (no
61
+ * API key, no host overrides). When `POSTHOG_API_KEY` is set without a host,
62
+ * the provider defaults to US Cloud, so the derived `https://us.posthog.com`
63
+ * is returned with `hostExplicit: false` — the CLI warns and confirms.
64
+ *
65
+ * Surfaces env ONLY — project-id discovery belongs to the provisioner (M8),
66
+ * and the webhook secret VALUE never leaves the server (only its
67
+ * configured-ness does).
68
+ */
69
+ export function resolveConnectInfo(env: {
70
+ POSTHOG_API_KEY?: string;
71
+ POSTHOG_HOST?: string;
72
+ POSTHOG_PRIVATE_HOST?: string;
73
+ POSTHOG_PROJECT_ID?: string;
74
+ POSTHOG_PERSONAL_API_KEY?: string;
75
+ POSTHOG_WEBHOOK_SECRET?: string;
76
+ API_PUBLIC_URL: string;
77
+ }): ConnectInfo {
78
+ const hostExplicit = Boolean(env.POSTHOG_PRIVATE_HOST ?? env.POSTHOG_HOST);
79
+ const configuredAtAll = Boolean(env.POSTHOG_API_KEY) || hostExplicit;
80
+ const privateHost = !configuredAtAll
81
+ ? null
82
+ : (
83
+ env.POSTHOG_PRIVATE_HOST ??
84
+ derivePrivateHost(env.POSTHOG_HOST ?? DEFAULT_HOST)
85
+ ).replace(/\/+$/, "");
86
+
87
+ return {
88
+ providerId: "posthog",
89
+ analyticsConfigured: Boolean(env.POSTHOG_API_KEY),
90
+ privateHost,
91
+ hostExplicit,
92
+ projectIdHint: env.POSTHOG_PROJECT_ID ?? null,
93
+ personalKeyConfigured: Boolean(env.POSTHOG_PERSONAL_API_KEY),
94
+ webhookSecretConfigured: Boolean(env.POSTHOG_WEBHOOK_SECRET),
95
+ apiPublicUrl: env.API_PUBLIC_URL,
96
+ };
97
+ }
98
+
99
+ const provisionLoopResponseSchema = z.object({
100
+ provisioned: z.literal(true),
101
+ /** `action === "created"` — false covers both "updated" and "unchanged". */
102
+ created: z.boolean(),
103
+ action: z.enum(["created", "updated", "unchanged"]),
104
+ hogFunctionId: z.string(),
105
+ webhookUrl: z.string(),
106
+ dashboardUrl: z.string(),
107
+ });
108
+
109
+ const provisionFailureSchema = z.object({
110
+ error: z.string(),
111
+ detail: z.string(),
112
+ remediation: z.string(),
113
+ });
114
+
115
+ const connectInfoRoute = createRoute({
116
+ method: "get",
117
+ path: "/connect-info",
118
+ tags: ["Admin — Analytics"],
119
+ summary: "PostHog connection info for `hogsend connect posthog`",
120
+ responses: {
121
+ 200: {
122
+ content: {
123
+ "application/json": { schema: connectInfoSchema },
124
+ },
125
+ description:
126
+ "The instance's PostHog env signal (region, readiness flags) — " +
127
+ "secrets never appear, only their configured-ness",
128
+ },
129
+ 401: {
130
+ content: { "application/json": { schema: errorSchema } },
131
+ description: "Missing or invalid admin credentials",
132
+ },
133
+ },
134
+ });
135
+
136
+ const provisionLoopRoute = createRoute({
137
+ method: "post",
138
+ path: "/provision-loop",
139
+ tags: ["Admin — Analytics"],
140
+ summary: "Provision the PostHog → Hogsend event loop (webhook destination)",
141
+ responses: {
142
+ 200: {
143
+ content: {
144
+ "application/json": { schema: provisionLoopResponseSchema },
145
+ },
146
+ description:
147
+ "Loop provisioned (idempotent: created, updated, or unchanged)",
148
+ },
149
+ 401: {
150
+ content: { "application/json": { schema: errorSchema } },
151
+ description: "Missing or invalid admin credentials",
152
+ },
153
+ 409: {
154
+ content: { "application/json": { schema: errorSchema } },
155
+ description:
156
+ "Refused: `no_posthog_credential` (no OAuth credential and no " +
157
+ "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)",
161
+ },
162
+ 502: {
163
+ content: { "application/json": { schema: provisionFailureSchema } },
164
+ description:
165
+ "PostHog rejected the provisioning call — error is the provisioner " +
166
+ "error code, remediation is operator-facing and printed verbatim",
167
+ },
168
+ },
169
+ });
170
+
171
+ export const analyticsAdminRouter = new OpenAPIHono<AppEnv>()
172
+ .openapi(connectInfoRoute, (c) => {
173
+ return c.json(resolveConnectInfo(c.get("container").env), 200);
174
+ })
175
+ .openapi(provisionLoopRoute, async (c) => {
176
+ const { db, env, logger } = c.get("container");
177
+ const info = resolveConnectInfo(env);
178
+
179
+ // Credential check FIRST (M3), secret refusal second (enforced by the
180
+ // provisioner itself). The container does NOT expose the token manager
181
+ // (it is closed over inside the provider's accessor), so a route-local
182
+ // instance is correct — the DB row is the shared truth and provisioning
183
+ // is a one-shot admin action.
184
+ const tokenManager = createTokenManager({
185
+ db,
186
+ providerId: "posthog",
187
+ logger,
188
+ });
189
+ const accessToken =
190
+ (await tokenManager.getAccessToken()) ??
191
+ env.POSTHOG_PERSONAL_API_KEY ??
192
+ null;
193
+ if (!accessToken) {
194
+ return c.json({ error: "no_posthog_credential" }, 409);
195
+ }
196
+
197
+ if (info.privateHost === null) {
198
+ // A credential exists but the server has no PostHog env signal to tell
199
+ // us which region to provision against.
200
+ return c.json({ error: "posthog_not_configured" }, 409);
201
+ }
202
+
203
+ // PostHog Cloud cannot deliver webhooks to a loopback address — and a
204
+ // local instance with a misconfigured API_PUBLIC_URL must never repoint
205
+ // a production destination at localhost. Refuse BEFORE any PostHog call.
206
+ if (isLoopbackPublicUrl(info.apiPublicUrl)) {
207
+ return c.json(
208
+ {
209
+ error: "api_public_url_unreachable",
210
+ detail: `API_PUBLIC_URL is ${info.apiPublicUrl} — PostHog cannot reach a loopback address.`,
211
+ remediation:
212
+ "Run this against your DEPLOYED instance (the credential is already stored there if you connected it): hogsend connect posthog --provision-only --url https://your-instance",
213
+ },
214
+ 409,
215
+ );
216
+ }
217
+
218
+ try {
219
+ const result = await provisionPostHogLoop({
220
+ privateHost: info.privateHost,
221
+ accessToken,
222
+ // M8: pass the env project id when set; else the provisioner runs its
223
+ // own one-shot `@current` discovery.
224
+ ...(env.POSTHOG_PROJECT_ID !== undefined
225
+ ? { projectId: env.POSTHOG_PROJECT_ID }
226
+ : {}),
227
+ apiPublicUrl: info.apiPublicUrl,
228
+ webhookSecret: env.POSTHOG_WEBHOOK_SECRET,
229
+ logger,
230
+ });
231
+
232
+ // M4 translation: the CLI's documented shape, with `action` riding
233
+ // along for --json consumers.
234
+ return c.json(
235
+ {
236
+ provisioned: true as const,
237
+ created: result.action === "created",
238
+ action: result.action,
239
+ hogFunctionId: result.functionId,
240
+ webhookUrl: result.webhookUrl,
241
+ dashboardUrl: result.dashboardUrl,
242
+ },
243
+ 200,
244
+ );
245
+ } catch (error) {
246
+ if (error instanceof ProvisionPostHogLoopError) {
247
+ if (error.code === "missing-webhook-secret") {
248
+ return c.json({ error: "webhook_secret_missing" }, 409);
249
+ }
250
+ return c.json(
251
+ {
252
+ error: error.code,
253
+ detail: error.message,
254
+ remediation: error.remediation,
255
+ },
256
+ 502,
257
+ );
258
+ }
259
+ throw error;
260
+ }
261
+ });
@@ -4,6 +4,7 @@ import { auditMiddleware } from "../../middleware/audit.js";
4
4
  import { rateLimit } from "../../middleware/rate-limit.js";
5
5
  import { requireAdmin } from "../../middleware/require-admin.js";
6
6
  import { alertsRouter } from "./alerts.js";
7
+ import { analyticsAdminRouter } from "./analytics.js";
7
8
  import { apiKeysRouter } from "./api-keys.js";
8
9
  import { auditLogsRouter } from "./audit-logs.js";
9
10
  import { bucketsRouter } from "./buckets.js";
@@ -17,6 +18,7 @@ import { journeyLogsRouter } from "./journey-logs.js";
17
18
  import { journeysRouter } from "./journeys.js";
18
19
  import { metricsRouter } from "./metrics.js";
19
20
  import { preferencesRouter } from "./preferences.js";
21
+ import { providerCredentialsRouter } from "./provider-credentials.js";
20
22
  import { reportingRouter } from "./reporting.js";
21
23
  import { suppressionsRouter } from "./suppressions.js";
22
24
  import { templatesRouter } from "./templates.js";
@@ -42,6 +44,8 @@ adminRouter.route("/templates", templatesRouter);
42
44
  adminRouter.route("/suppressions", suppressionsRouter);
43
45
  adminRouter.route("/api-keys", apiKeysRouter);
44
46
  adminRouter.route("/webhooks", webhooksRouter);
47
+ adminRouter.route("/provider-credentials", providerCredentialsRouter);
48
+ adminRouter.route("/analytics", analyticsAdminRouter);
45
49
  adminRouter.route("/audit-logs", auditLogsRouter);
46
50
  adminRouter.route("/alerts", alertsRouter);
47
51
  adminRouter.route("/dlq", dlqRouter);
@@ -0,0 +1,184 @@
1
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
2
+ import type { AppEnv } from "../../app.js";
3
+ import {
4
+ deleteProviderCredential,
5
+ getProviderCredential,
6
+ ProviderCredentialDecryptError,
7
+ type ProviderCredentialMeta,
8
+ saveProviderCredential,
9
+ toCredentialMeta,
10
+ } from "../../lib/provider-credentials.js";
11
+ import { errorSchema } from "../../lib/schemas.js";
12
+
13
+ /**
14
+ * Admin provider-credential management. Mounted at
15
+ * `/v1/admin/provider-credentials`, it inherits `requireAdmin` + `rateLimit`
16
+ * + `auditMiddleware` from the admin router root — no per-route auth here.
17
+ *
18
+ * INVARIANT: decrypted token material NEVER appears in any HTTP response —
19
+ * this router returns meta only. (The audit middleware is body-blind, so a
20
+ * PUT body carrying tokens is not persisted to `audit_logs` either.)
21
+ */
22
+
23
+ const providerIdParam = z.object({
24
+ providerId: z.string().min(1).max(100),
25
+ });
26
+
27
+ // The canonical OAuth credential payload (SYNTHESIS §0) — keep textually
28
+ // identical to the token manager's runtime parse.
29
+ const oauthPayloadSchema = z.object({
30
+ accessToken: z.string().min(1),
31
+ refreshToken: z.string().min(1),
32
+ expiresAt: z.string().datetime({ offset: true }),
33
+ tokenEndpoint: z.string().url(),
34
+ clientId: z.string().url(),
35
+ scopes: z.array(z.string()).default([]),
36
+ scopedTeams: z.array(z.number().int()).default([]),
37
+ scopedOrganizations: z.array(z.string()).default([]),
38
+ });
39
+
40
+ const putBodySchema = z.object({
41
+ kind: z.literal("oauth").default("oauth"),
42
+ payload: oauthPayloadSchema,
43
+ });
44
+
45
+ // Wire meta — Dates serialized to ISO strings. Token material never appears.
46
+ const credentialMetaSchema = z.object({
47
+ providerId: z.string(),
48
+ kind: z.literal("oauth"),
49
+ scopes: z.array(z.string()),
50
+ expiresAt: z.string(),
51
+ scopedTeams: z.array(z.number()),
52
+ createdAt: z.string(),
53
+ updatedAt: z.string(),
54
+ });
55
+
56
+ const upsertRoute = createRoute({
57
+ method: "put",
58
+ path: "/{providerId}",
59
+ tags: ["Admin — Provider Credentials"],
60
+ summary: "Store (upsert) a provider credential",
61
+ request: {
62
+ params: providerIdParam,
63
+ body: {
64
+ content: {
65
+ "application/json": { schema: putBodySchema },
66
+ },
67
+ },
68
+ },
69
+ responses: {
70
+ 200: {
71
+ content: {
72
+ "application/json": { schema: credentialMetaSchema },
73
+ },
74
+ description:
75
+ "Credential stored (encrypted at rest) — meta only, tokens are never returned",
76
+ },
77
+ },
78
+ });
79
+
80
+ const getRoute = createRoute({
81
+ method: "get",
82
+ path: "/{providerId}",
83
+ tags: ["Admin — Provider Credentials"],
84
+ summary: "Get a provider credential's meta",
85
+ request: {
86
+ params: providerIdParam,
87
+ },
88
+ responses: {
89
+ 200: {
90
+ content: {
91
+ "application/json": { schema: credentialMetaSchema },
92
+ },
93
+ description: "Credential meta — decrypted tokens are NEVER returned",
94
+ },
95
+ 404: {
96
+ content: { "application/json": { schema: errorSchema } },
97
+ description: "No credential stored for this provider",
98
+ },
99
+ 409: {
100
+ content: { "application/json": { schema: errorSchema } },
101
+ description:
102
+ "Credential exists but cannot be decrypted — BETTER_AUTH_SECRET rotated; PUT a new credential or DELETE",
103
+ },
104
+ },
105
+ });
106
+
107
+ const deleteRoute = createRoute({
108
+ method: "delete",
109
+ path: "/{providerId}",
110
+ tags: ["Admin — Provider Credentials"],
111
+ summary: "Delete a provider credential",
112
+ request: {
113
+ params: providerIdParam,
114
+ },
115
+ responses: {
116
+ 200: {
117
+ content: {
118
+ "application/json": { schema: z.object({ deleted: z.boolean() }) },
119
+ },
120
+ description: "Credential hard-deleted",
121
+ },
122
+ 404: {
123
+ content: { "application/json": { schema: errorSchema } },
124
+ description: "No credential stored for this provider",
125
+ },
126
+ },
127
+ });
128
+
129
+ function serializeMeta(meta: ProviderCredentialMeta) {
130
+ return {
131
+ providerId: meta.providerId,
132
+ kind: meta.kind,
133
+ scopes: meta.scopes,
134
+ expiresAt: meta.expiresAt.toISOString(),
135
+ scopedTeams: meta.scopedTeams,
136
+ createdAt: meta.createdAt.toISOString(),
137
+ updatedAt: meta.updatedAt.toISOString(),
138
+ };
139
+ }
140
+
141
+ export const providerCredentialsRouter = new OpenAPIHono<AppEnv>()
142
+ .openapi(upsertRoute, async (c) => {
143
+ const { db } = c.get("container");
144
+ const { providerId } = c.req.valid("param");
145
+ const body = c.req.valid("json");
146
+
147
+ // PUT is a full idempotent upsert — create and update are the same 200.
148
+ const meta = await saveProviderCredential(db, {
149
+ providerId,
150
+ kind: body.kind,
151
+ payload: body.payload,
152
+ });
153
+
154
+ return c.json(serializeMeta(meta), 200);
155
+ })
156
+ .openapi(getRoute, async (c) => {
157
+ const { db } = c.get("container");
158
+ const { providerId } = c.req.valid("param");
159
+
160
+ try {
161
+ const record = await getProviderCredential(db, providerId);
162
+ if (!record) {
163
+ return c.json({ error: "Provider credential not found" }, 404);
164
+ }
165
+ return c.json(serializeMeta(toCredentialMeta(record)), 200);
166
+ } catch (error) {
167
+ if (error instanceof ProviderCredentialDecryptError) {
168
+ return c.json({ error: error.message }, 409);
169
+ }
170
+ throw error;
171
+ }
172
+ })
173
+ .openapi(deleteRoute, async (c) => {
174
+ const { db } = c.get("container");
175
+ const { providerId } = c.req.valid("param");
176
+
177
+ // Never decrypts — DELETE must succeed even when the payload is
178
+ // undecryptable (the operator's escape hatch after a secret rotation).
179
+ const deleted = await deleteProviderCredential(db, providerId);
180
+ if (!deleted) {
181
+ return c.json({ error: "Provider credential not found" }, 404);
182
+ }
183
+ return c.json({ deleted: true }, 200);
184
+ });