@hogsend/engine 0.18.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.
@@ -0,0 +1,722 @@
1
+ import type { Logger } from "./logger.js";
2
+
3
+ /**
4
+ * Idempotent provisioner for the "PostHog → Hogsend" webhook destination —
5
+ * the PostHog-side half of the event loop `hogsend connect posthog` sets up
6
+ * (a hog function, `type: "destination"`, template `template-webhook`,
7
+ * POSTing identified events to `${apiPublicUrl}/v1/webhooks/posthog`).
8
+ *
9
+ * Pure HTTP (global `fetch`) — no DB, no Hatchet. Adoption matches by the
10
+ * webhook URL's pathname (endsWith /v1/webhooks/posthog), not by name, so
11
+ * renamed functions, host migrations, and legacy Go-CLI creations are
12
+ * adopted rather than duplicated. Reconciliation enforces only what we
13
+ * manage (url/method/body, our two headers, the $is_identified filter,
14
+ * enabled) and preserves operator customization (extra headers, extra
15
+ * filter properties, debug, name/description).
16
+ *
17
+ * The webhook secret deliberately rides in a NON-secret header input
18
+ * (matching the template): secret inputs are redacted on GET, which would
19
+ * break adopt-diffing and secret-rotation detection. Anyone with PostHog
20
+ * project access can read it — same trust domain, acceptable.
21
+ */
22
+
23
+ export interface ProvisionPostHogLoopOptions {
24
+ /**
25
+ * Private (app) API host, e.g. https://eu.posthog.com — ALREADY derived
26
+ * (derivePrivateHost / POSTHOG_PRIVATE_HOST). Never the ingestion host.
27
+ */
28
+ privateHost: string;
29
+ /** Bearer credential: OAuth access token (pha_) or personal key (phx_). */
30
+ accessToken: string;
31
+ /** Skip /api/projects/@current/ discovery when known. */
32
+ projectId?: string | number;
33
+ /**
34
+ * Public base URL of THIS engine (env API_PUBLIC_URL), no trailing slash
35
+ * needed.
36
+ */
37
+ apiPublicUrl: string;
38
+ /**
39
+ * Shared secret the consumer's posthog webhook source matches
40
+ * (env POSTHOG_WEBHOOK_SECRET). `undefined`/"" ⇒ REFUSE: match-auth
41
+ * webhook sources are OPEN when their secret env is unset, so
42
+ * provisioning without one would point PostHog at an unauthenticated
43
+ * ingest route.
44
+ */
45
+ webhookSecret: string | undefined;
46
+ logger: Logger;
47
+ /** Display name for a NEWLY created function. Default: MANAGED_NAME. */
48
+ name?: string;
49
+ }
50
+
51
+ export interface ProvisionPostHogLoopResult {
52
+ action: "created" | "updated" | "unchanged";
53
+ /** Hog function UUID. */
54
+ functionId: string;
55
+ /** Resolved numeric project id (stringified). */
56
+ projectId: string;
57
+ /** The URL the destination POSTs to: `${apiPublicUrl}/v1/webhooks/posthog`. */
58
+ webhookUrl: string;
59
+ /** Best-effort deep link (pattern unverified — cosmetic, confirm in e2e). */
60
+ dashboardUrl: string;
61
+ }
62
+
63
+ export type ProvisionPostHogLoopErrorCode =
64
+ | "missing-webhook-secret" // refused before any network call
65
+ | "unauthorized" // HTTP 401 — bad/expired token
66
+ | "missing-scope" // HTTP 403 — token lacks a required scope
67
+ | "unsupported-instance" // 404 on the hog_functions collection
68
+ | "project-discovery-failed" // @current failed and no projectId given
69
+ | "api-error"; // any other non-2xx / network failure
70
+
71
+ export class ProvisionPostHogLoopError extends Error {
72
+ readonly code: ProvisionPostHogLoopErrorCode;
73
+ /** Operator-facing remediation — the CLI/admin route prints it VERBATIM. */
74
+ readonly remediation: string;
75
+ readonly status?: number;
76
+
77
+ constructor(opts: {
78
+ code: ProvisionPostHogLoopErrorCode;
79
+ message: string;
80
+ remediation: string;
81
+ status?: number;
82
+ }) {
83
+ super(opts.message);
84
+ this.name = "ProvisionPostHogLoopError";
85
+ this.code = opts.code;
86
+ this.remediation = opts.remediation;
87
+ this.status = opts.status;
88
+ }
89
+ }
90
+
91
+ /** Path marker that identifies the Hogsend loop regardless of host. */
92
+ const HOGSEND_LOOP_PATH = "/v1/webhooks/posthog";
93
+ const MANAGED_NAME = "Hogsend ingest — identified events";
94
+ const MANAGED_DESCRIPTION =
95
+ "Forwards identified PostHog events to Hogsend. Managed by " +
96
+ "`hogsend connect posthog` — safe to re-run; extra headers and " +
97
+ "filters you add here are preserved.";
98
+ const IS_IDENTIFIED_FILTER = {
99
+ key: "$is_identified",
100
+ type: "event",
101
+ value: ["true"],
102
+ operator: "exact",
103
+ } as const;
104
+ const CANONICAL_BODY = { event: "{event}", person: "{person}" } as const;
105
+ const FETCH_TIMEOUT_MS = 15_000;
106
+ /** Detail-GET budget while hunting for an adoptable function. */
107
+ const MAX_ADOPT_PROBES = 25;
108
+ /** Pagination guard — a misbehaving `next` must never loop forever. */
109
+ const MAX_LIST_PAGES = 20;
110
+
111
+ /**
112
+ * Canonical `template-webhook` hog source — PATCHed onto legacy Go-CLI
113
+ * functions (whose custom hog reads `inputs.payload`) so the five-key
114
+ * canonical inputs drive the request after normalization.
115
+ */
116
+ const TEMPLATE_WEBHOOK_HOG = `let payload := {
117
+ 'headers': inputs.headers,
118
+ 'body': inputs.body,
119
+ 'method': inputs.method
120
+ }
121
+
122
+ if (inputs.debug) {
123
+ print('Request', inputs.url, payload)
124
+ }
125
+
126
+ let res := fetch(inputs.url, payload);
127
+
128
+ if (res.status >= 400) {
129
+ throw Error(f'Webhook failed with status {res.status}: {res.body}');
130
+ }
131
+
132
+ if (inputs.debug) {
133
+ print('Response', res.status, res.body);
134
+ }`;
135
+
136
+ /** Canonical `template-webhook` inputs_schema (five fields, verbatim). */
137
+ const TEMPLATE_WEBHOOK_INPUTS_SCHEMA = [
138
+ {
139
+ type: "string",
140
+ key: "url",
141
+ label: "Webhook URL",
142
+ required: true,
143
+ secret: false,
144
+ hidden: false,
145
+ description: "Endpoint URL to send event data to.",
146
+ },
147
+ {
148
+ type: "choice",
149
+ key: "method",
150
+ label: "Method",
151
+ choices: [
152
+ { label: "POST", value: "POST" },
153
+ { label: "PUT", value: "PUT" },
154
+ { label: "PATCH", value: "PATCH" },
155
+ { label: "GET", value: "GET" },
156
+ { label: "DELETE", value: "DELETE" },
157
+ ],
158
+ required: false,
159
+ default: "POST",
160
+ secret: false,
161
+ hidden: false,
162
+ description: "HTTP method to use for the request.",
163
+ },
164
+ {
165
+ type: "json",
166
+ key: "body",
167
+ label: "JSON Body",
168
+ required: false,
169
+ default: { event: "{event}", person: "{person}" },
170
+ secret: false,
171
+ hidden: false,
172
+ description: "JSON payload to send in the request body.",
173
+ },
174
+ {
175
+ type: "dictionary",
176
+ key: "headers",
177
+ label: "Headers",
178
+ required: false,
179
+ default: { "Content-Type": "application/json" },
180
+ secret: false,
181
+ hidden: false,
182
+ description: "HTTP headers to send in the request.",
183
+ },
184
+ {
185
+ type: "boolean",
186
+ key: "debug",
187
+ label: "Log responses",
188
+ required: false,
189
+ default: false,
190
+ secret: false,
191
+ hidden: false,
192
+ description: "Logs the response of http calls for debugging.",
193
+ },
194
+ ];
195
+
196
+ const MISSING_SECRET_REMEDIATION =
197
+ "Without POSTHOG_WEBHOOK_SECRET the POST /v1/webhooks/posthog route " +
198
+ "accepts UNAUTHENTICATED traffic (match-auth webhook sources are open " +
199
+ "when their secret is unset). Generate one (e.g. openssl rand -hex 32), " +
200
+ "set POSTHOG_WEBHOOK_SECRET on both the API and worker services, " +
201
+ "redeploy, then run `hogsend connect posthog --provision-only`.";
202
+ const UNAUTHORIZED_REMEDIATION =
203
+ "The PostHog credential was rejected. Re-run hogsend connect posthog " +
204
+ "to obtain a fresh token, or check POSTHOG_PERSONAL_API_KEY.";
205
+ const MISSING_SCOPE_REMEDIATION =
206
+ "The PostHog credential lacks a required scope. The connect flow " +
207
+ "needs: hog_function:write, project:read (plus person:read, " +
208
+ "person:write for person access). For a personal API key, edit its " +
209
+ "scopes in PostHog → Settings → Personal API keys.";
210
+ const UNSUPPORTED_INSTANCE_REMEDIATION =
211
+ "This PostHog instance does not expose the hog functions API. Set the " +
212
+ "webhook destination up manually (docs: " +
213
+ "/docs/getting-started/posthog-setup) or upgrade PostHog.";
214
+ const PROJECT_DISCOVERY_REMEDIATION =
215
+ "Could not discover the PostHog project id. Pass projectId explicitly " +
216
+ "or set POSTHOG_PROJECT_ID.";
217
+ const API_ERROR_REMEDIATION =
218
+ "PostHog returned an unexpected error. Check the status and detail " +
219
+ "above, then re-run `hogsend connect posthog --provision-only`.";
220
+
221
+ /** Internal — only the detail fields the provisioner reads. */
222
+ interface HogFunctionDetail {
223
+ id: string;
224
+ enabled: boolean;
225
+ inputs: Record<string, { value?: unknown } | null> | null;
226
+ filters: {
227
+ source?: string;
228
+ properties?: unknown[];
229
+ bytecode?: unknown;
230
+ [k: string]: unknown;
231
+ } | null;
232
+ inputs_schema?: Array<{ key: string }>;
233
+ template?: { id?: string } | null;
234
+ name: string;
235
+ }
236
+
237
+ interface DesiredLoop {
238
+ webhookUrl: string;
239
+ webhookSecret: string;
240
+ }
241
+
242
+ /**
243
+ * Idempotently create-or-adopt the "PostHog → Hogsend" hog-function
244
+ * destination. Safe to re-run: an already-compliant function is left
245
+ * untouched ("unchanged"), a drifted one is reconciled ("updated"), and
246
+ * only when no function POSTs to this engine's `/v1/webhooks/posthog`
247
+ * path is a new one created ("created").
248
+ */
249
+ export async function provisionPostHogLoop(
250
+ opts: ProvisionPostHogLoopOptions,
251
+ ): Promise<ProvisionPostHogLoopResult> {
252
+ const { privateHost, accessToken, apiPublicUrl, webhookSecret, logger } =
253
+ opts;
254
+
255
+ if (!webhookSecret) {
256
+ throw new ProvisionPostHogLoopError({
257
+ code: "missing-webhook-secret",
258
+ message: "POSTHOG_WEBHOOK_SECRET is not set — refusing to provision.",
259
+ remediation: MISSING_SECRET_REMEDIATION,
260
+ });
261
+ }
262
+
263
+ const webhookUrl = joinUrl(apiPublicUrl, HOGSEND_LOOP_PATH);
264
+ const hostname = tryParseUrl(webhookUrl)?.hostname;
265
+ if (hostname === "localhost" || hostname === "127.0.0.1") {
266
+ logger.warn(
267
+ "API_PUBLIC_URL is a loopback host — PostHog Cloud cannot reach it",
268
+ { url: webhookUrl },
269
+ );
270
+ }
271
+
272
+ const projectId =
273
+ opts.projectId !== undefined
274
+ ? String(opts.projectId)
275
+ : await discoverProjectId({ privateHost, accessToken });
276
+
277
+ const basePath = `/api/environments/${projectId}/hog_functions/`;
278
+ const desired: DesiredLoop = { webhookUrl, webhookSecret };
279
+
280
+ const found = await findLoopFunction({ privateHost, accessToken, basePath });
281
+
282
+ let action: ProvisionPostHogLoopResult["action"];
283
+ let functionId: string;
284
+
285
+ if (found && isCompliant(found, desired)) {
286
+ action = "unchanged";
287
+ functionId = found.id;
288
+ } else if (found) {
289
+ await phFetch({
290
+ privateHost,
291
+ accessToken,
292
+ path: `${basePath}${found.id}/`,
293
+ method: "PATCH",
294
+ body: buildUpdatePayload(found, desired),
295
+ });
296
+ action = "updated";
297
+ functionId = found.id;
298
+ } else {
299
+ const created = await phFetch({
300
+ privateHost,
301
+ accessToken,
302
+ path: basePath,
303
+ method: "POST",
304
+ body: buildCreatePayload(desired, opts.name),
305
+ notFoundMeansUnsupported: true,
306
+ });
307
+ const id = isRecord(created) ? created.id : undefined;
308
+ if (typeof id !== "string" && typeof id !== "number") {
309
+ throw new ProvisionPostHogLoopError({
310
+ code: "api-error",
311
+ message: "PostHog created the hog function but returned no id.",
312
+ remediation: API_ERROR_REMEDIATION,
313
+ });
314
+ }
315
+ action = "created";
316
+ functionId = String(id);
317
+ }
318
+
319
+ logger.info("Provisioned PostHog → Hogsend loop", {
320
+ action,
321
+ functionId,
322
+ projectId,
323
+ url: webhookUrl,
324
+ });
325
+
326
+ return {
327
+ action,
328
+ functionId,
329
+ projectId,
330
+ webhookUrl,
331
+ dashboardUrl:
332
+ `${privateHost.replace(/\/+$/, "")}/project/${projectId}` +
333
+ `/pipeline/destinations/hog-${functionId}/configuration`,
334
+ };
335
+ }
336
+
337
+ /**
338
+ * One-shot `@current` discovery, used only when `opts.projectId` is not
339
+ * given. Deliberately uncached and NOT shared with plugin-posthog's
340
+ * `resolveProjectId` — different process/cadence; provisioning is a
341
+ * one-shot admin action that tolerates a stray re-discovery.
342
+ */
343
+ async function discoverProjectId(opts: {
344
+ privateHost: string;
345
+ accessToken: string;
346
+ }): Promise<string> {
347
+ let body: unknown;
348
+ try {
349
+ body = await phFetch({
350
+ privateHost: opts.privateHost,
351
+ accessToken: opts.accessToken,
352
+ path: "/api/projects/@current/",
353
+ });
354
+ } catch (err) {
355
+ if (
356
+ err instanceof ProvisionPostHogLoopError &&
357
+ (err.code === "unauthorized" || err.code === "missing-scope")
358
+ ) {
359
+ throw err;
360
+ }
361
+ throw new ProvisionPostHogLoopError({
362
+ code: "project-discovery-failed",
363
+ message: `PostHog project discovery failed: ${
364
+ err instanceof Error ? err.message : String(err)
365
+ }`,
366
+ remediation: PROJECT_DISCOVERY_REMEDIATION,
367
+ });
368
+ }
369
+ const id = isRecord(body) ? body.id : undefined;
370
+ if (typeof id !== "number" && typeof id !== "string") {
371
+ throw new ProvisionPostHogLoopError({
372
+ code: "project-discovery-failed",
373
+ message: "PostHog /api/projects/@current/ returned no project id.",
374
+ remediation: PROJECT_DISCOVERY_REMEDIATION,
375
+ });
376
+ }
377
+ return String(id);
378
+ }
379
+
380
+ /** Bearer + JSON fetch with the documented error mapping. */
381
+ async function phFetch(opts: {
382
+ privateHost: string;
383
+ accessToken: string;
384
+ /** e.g. `/api/environments/${id}/hog_functions/` */
385
+ path: string;
386
+ method?: string;
387
+ body?: unknown;
388
+ /** 404 here means "instance has no hog functions" (self-hosted/old). */
389
+ notFoundMeansUnsupported?: boolean;
390
+ }): Promise<unknown> {
391
+ const url = joinUrl(opts.privateHost, opts.path);
392
+ let res: Response;
393
+ try {
394
+ res = await fetch(url, {
395
+ method: opts.method ?? "GET",
396
+ headers: {
397
+ Authorization: `Bearer ${opts.accessToken}`,
398
+ ...(opts.body !== undefined
399
+ ? { "Content-Type": "application/json" }
400
+ : {}),
401
+ },
402
+ ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
403
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
404
+ });
405
+ } catch (err) {
406
+ throw new ProvisionPostHogLoopError({
407
+ code: "api-error",
408
+ message: `PostHog request failed: ${
409
+ err instanceof Error ? err.message : String(err)
410
+ }`,
411
+ remediation: API_ERROR_REMEDIATION,
412
+ });
413
+ }
414
+
415
+ if (!res.ok) {
416
+ const detail = await readErrorDetail(res);
417
+ if (res.status === 401) {
418
+ throw new ProvisionPostHogLoopError({
419
+ code: "unauthorized",
420
+ message: `PostHog rejected the credential (401): ${detail}`,
421
+ remediation: UNAUTHORIZED_REMEDIATION,
422
+ status: 401,
423
+ });
424
+ }
425
+ if (res.status === 403) {
426
+ throw new ProvisionPostHogLoopError({
427
+ code: "missing-scope",
428
+ message: `PostHog denied the request (403): ${detail}`,
429
+ remediation: MISSING_SCOPE_REMEDIATION,
430
+ status: 403,
431
+ });
432
+ }
433
+ if (res.status === 404 && opts.notFoundMeansUnsupported) {
434
+ throw new ProvisionPostHogLoopError({
435
+ code: "unsupported-instance",
436
+ message: `PostHog has no hog functions API (404): ${detail}`,
437
+ remediation: UNSUPPORTED_INSTANCE_REMEDIATION,
438
+ status: 404,
439
+ });
440
+ }
441
+ throw new ProvisionPostHogLoopError({
442
+ code: "api-error",
443
+ message: `PostHog request failed (${res.status}): ${detail}`,
444
+ remediation: API_ERROR_REMEDIATION,
445
+ status: res.status,
446
+ });
447
+ }
448
+
449
+ try {
450
+ return await res.json();
451
+ } catch {
452
+ return undefined;
453
+ }
454
+ }
455
+
456
+ /** PostHog error bodies are `{ type, code, detail, attr }` — prefer detail. */
457
+ async function readErrorDetail(res: Response): Promise<string> {
458
+ const text = await res.text().catch(() => "");
459
+ let detail = text;
460
+ try {
461
+ const parsed: unknown = JSON.parse(text);
462
+ if (isRecord(parsed) && typeof parsed.detail === "string") {
463
+ detail = parsed.detail;
464
+ }
465
+ } catch {
466
+ // non-JSON body — keep the raw text
467
+ }
468
+ return detail.length > 200 ? `${detail.slice(0, 200)}…` : detail;
469
+ }
470
+
471
+ /**
472
+ * Paginate the destination list, detail-GET candidates (hogsend-named
473
+ * first), and return the first function whose `inputs.url` parses and
474
+ * whose pathname ends with HOGSEND_LOOP_PATH. Matching by URL (not name)
475
+ * is the stable marker: it survives renames AND host migrations, and the
476
+ * legacy Go-CLI functions are adopted by the same rule.
477
+ */
478
+ async function findLoopFunction(opts: {
479
+ privateHost: string;
480
+ accessToken: string;
481
+ basePath: string;
482
+ }): Promise<HogFunctionDetail | undefined> {
483
+ const candidates: Array<{ id: string; name: string }> = [];
484
+ let path: string | undefined = `${opts.basePath}?type=destination&limit=100`;
485
+
486
+ for (let page = 0; path && page < MAX_LIST_PAGES; page++) {
487
+ const listing = await phFetch({
488
+ privateHost: opts.privateHost,
489
+ accessToken: opts.accessToken,
490
+ path,
491
+ notFoundMeansUnsupported: true,
492
+ });
493
+ const results =
494
+ isRecord(listing) && Array.isArray(listing.results)
495
+ ? listing.results
496
+ : [];
497
+ for (const item of results) {
498
+ if (!isRecord(item) || item.id === undefined || item.id === null) {
499
+ continue;
500
+ }
501
+ // ?type=destination is server-side; keep a client-side check in case
502
+ // a PostHog release drops the filter.
503
+ if (typeof item.type === "string" && item.type !== "destination") {
504
+ continue;
505
+ }
506
+ candidates.push({
507
+ id: String(item.id),
508
+ name: typeof item.name === "string" ? item.name : "",
509
+ });
510
+ }
511
+ const next = isRecord(listing) ? listing.next : undefined;
512
+ path = typeof next === "string" && next ? pathAndSearch(next) : undefined;
513
+ }
514
+
515
+ // List items carry no `inputs`, so matching needs a detail GET per
516
+ // candidate — probe likely matches (hogsend-named) first, on a budget.
517
+ const named = candidates.filter((c) =>
518
+ c.name.toLowerCase().includes("hogsend"),
519
+ );
520
+ const rest = candidates.filter(
521
+ (c) => !c.name.toLowerCase().includes("hogsend"),
522
+ );
523
+ const ordered = [...named, ...rest].slice(0, MAX_ADOPT_PROBES);
524
+
525
+ for (const candidate of ordered) {
526
+ const detail = await phFetch({
527
+ privateHost: opts.privateHost,
528
+ accessToken: opts.accessToken,
529
+ path: `${opts.basePath}${candidate.id}/`,
530
+ });
531
+ if (!isRecord(detail)) continue;
532
+ const fn = detail as unknown as HogFunctionDetail;
533
+ const urlValue = inputValue(fn.inputs, "url");
534
+ if (typeof urlValue !== "string") continue;
535
+ const parsed = tryParseUrl(urlValue);
536
+ if (parsed?.pathname.endsWith(HOGSEND_LOOP_PATH)) return fn;
537
+ }
538
+ return undefined;
539
+ }
540
+
541
+ /** true ⇒ no PATCH needed. */
542
+ function isCompliant(fn: HogFunctionDetail, desired: DesiredLoop): boolean {
543
+ if (fn.enabled !== true) return false;
544
+ if (inputValue(fn.inputs, "url") !== desired.webhookUrl) return false;
545
+ if (inputValue(fn.inputs, "method") !== "POST") return false;
546
+ if (!deepEquals(inputValue(fn.inputs, "body"), CANONICAL_BODY)) return false;
547
+ const headers = inputValue(fn.inputs, "headers");
548
+ if (!isRecord(headers)) return false;
549
+ if (headers["Content-Type"] !== "application/json") return false;
550
+ if (headers["x-posthog-webhook-secret"] !== desired.webhookSecret) {
551
+ return false;
552
+ }
553
+ if (!hasIdentifiedFilter(fn.filters?.properties)) return false;
554
+ return hasCanonicalSchema(fn.inputs_schema);
555
+ }
556
+
557
+ /** Canonical five-key schema, and NO legacy Go-CLI `payload` key. */
558
+ function hasCanonicalSchema(
559
+ schema: HogFunctionDetail["inputs_schema"],
560
+ ): boolean {
561
+ const keys = new Set((schema ?? []).map((field) => field.key));
562
+ if (keys.has("payload")) return false;
563
+ return ["url", "method", "body", "headers", "debug"].every((key) =>
564
+ keys.has(key),
565
+ );
566
+ }
567
+
568
+ function hasIdentifiedFilter(properties: unknown[] | undefined): boolean {
569
+ return (properties ?? []).some(isCompliantIdentifiedEntry);
570
+ }
571
+
572
+ function isCompliantIdentifiedEntry(entry: unknown): boolean {
573
+ if (!isRecord(entry)) return false;
574
+ if (entry.key !== "$is_identified" || entry.operator !== "exact") {
575
+ return false;
576
+ }
577
+ // Accept ["true"], [true], "true", true — the UI writes ["true"], other
578
+ // writers vary; representation differences must not churn a PATCH.
579
+ const value =
580
+ Array.isArray(entry.value) && entry.value.length === 1
581
+ ? entry.value[0]
582
+ : entry.value;
583
+ return value === true || value === "true";
584
+ }
585
+
586
+ function buildCreatePayload(
587
+ desired: DesiredLoop,
588
+ name: string | undefined,
589
+ ): Record<string, unknown> {
590
+ return {
591
+ type: "destination",
592
+ name: name ?? MANAGED_NAME,
593
+ description: MANAGED_DESCRIPTION,
594
+ template_id: "template-webhook",
595
+ enabled: true,
596
+ inputs: {
597
+ url: { value: desired.webhookUrl },
598
+ method: { value: "POST" },
599
+ body: { value: { ...CANONICAL_BODY } },
600
+ headers: {
601
+ value: {
602
+ "Content-Type": "application/json",
603
+ "x-posthog-webhook-secret": desired.webhookSecret,
604
+ },
605
+ },
606
+ debug: { value: false },
607
+ },
608
+ filters: {
609
+ source: "events",
610
+ properties: [IS_IDENTIFIED_FILTER],
611
+ },
612
+ };
613
+ }
614
+
615
+ /**
616
+ * Reconciled PATCH body: enforce what we manage, preserve the rest.
617
+ * `enabled: true` always — connect is an explicit operator action, so
618
+ * re-enabling a paused loop is the expected outcome. PostHog replaces
619
+ * `inputs` WHOLESALE on PATCH (verified), so all five keys are always
620
+ * sent. `name`/`description` are never touched on adopt — the operator
621
+ * may have renamed deliberately.
622
+ */
623
+ function buildUpdatePayload(
624
+ fn: HogFunctionDetail,
625
+ desired: DesiredLoop,
626
+ ): Record<string, unknown> {
627
+ const currentHeaders = inputValue(fn.inputs, "headers");
628
+ const extraHeaders = isRecord(currentHeaders) ? currentHeaders : {};
629
+ const currentDebug = inputValue(fn.inputs, "debug");
630
+
631
+ const filters: Record<string, unknown> = isRecord(fn.filters)
632
+ ? { ...fn.filters }
633
+ : {};
634
+ // Never send stale bytecode back — the server recompiles (verified).
635
+ delete filters.bytecode;
636
+ filters.source = "events";
637
+ const properties = Array.isArray(filters.properties)
638
+ ? [...filters.properties]
639
+ : [];
640
+ if (!properties.some(isCompliantIdentifiedEntry)) {
641
+ properties.push(IS_IDENTIFIED_FILTER);
642
+ }
643
+ filters.properties = properties;
644
+
645
+ const payload: Record<string, unknown> = {
646
+ enabled: true,
647
+ inputs: {
648
+ url: { value: desired.webhookUrl },
649
+ method: { value: "POST" },
650
+ body: { value: { ...CANONICAL_BODY } },
651
+ // Operator-added headers survive; ours win on collision.
652
+ headers: {
653
+ value: {
654
+ ...extraHeaders,
655
+ "Content-Type": "application/json",
656
+ "x-posthog-webhook-secret": desired.webhookSecret,
657
+ },
658
+ },
659
+ debug: {
660
+ value: typeof currentDebug === "boolean" ? currentDebug : false,
661
+ },
662
+ },
663
+ filters,
664
+ };
665
+
666
+ // Legacy Go-CLI normalization: its functions carry a custom hog source
667
+ // reading `inputs.payload` — rewrite both hog and inputs_schema to the
668
+ // canonical template shape so the five canonical inputs take effect.
669
+ if (!hasCanonicalSchema(fn.inputs_schema)) {
670
+ payload.hog = TEMPLATE_WEBHOOK_HOG;
671
+ payload.inputs_schema = TEMPLATE_WEBHOOK_INPUTS_SCHEMA;
672
+ }
673
+
674
+ return payload;
675
+ }
676
+
677
+ function inputValue(inputs: HogFunctionDetail["inputs"], key: string): unknown {
678
+ const entry = inputs?.[key];
679
+ return isRecord(entry) ? entry.value : undefined;
680
+ }
681
+
682
+ /**
683
+ * `base.replace(/\/+$/, "") + path` on purpose — `new URL(path, base)`
684
+ * drops path prefixes on the base, breaking path-prefixed deployments.
685
+ */
686
+ function joinUrl(base: string, path: string): string {
687
+ return base.replace(/\/+$/, "") + path;
688
+ }
689
+
690
+ function tryParseUrl(value: string): URL | null {
691
+ try {
692
+ return new URL(value);
693
+ } catch {
694
+ return null;
695
+ }
696
+ }
697
+
698
+ /** DRF `next` is absolute — re-issue through phFetch as path+search. */
699
+ function pathAndSearch(absolute: string): string {
700
+ const parsed = tryParseUrl(absolute);
701
+ return parsed ? `${parsed.pathname}${parsed.search}` : absolute;
702
+ }
703
+
704
+ function isRecord(value: unknown): value is Record<string, unknown> {
705
+ return typeof value === "object" && value !== null && !Array.isArray(value);
706
+ }
707
+
708
+ function deepEquals(a: unknown, b: unknown): boolean {
709
+ if (a === b) return true;
710
+ if (Array.isArray(a) && Array.isArray(b)) {
711
+ return a.length === b.length && a.every((v, i) => deepEquals(v, b[i]));
712
+ }
713
+ if (isRecord(a) && isRecord(b)) {
714
+ const aKeys = Object.keys(a);
715
+ const bKeys = Object.keys(b);
716
+ return (
717
+ aKeys.length === bKeys.length &&
718
+ aKeys.every((k) => deepEquals(a[k], b[k]))
719
+ );
720
+ }
721
+ return false;
722
+ }