@hogsend/cli 0.7.0 → 0.9.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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: hogsend-webhooks-and-workflows
3
- description: Use when adding an inbound webhook source in src/webhook-sources/ (defineWebhookSource — auth header + envKey, optional Zod schema, transform(payload, ctx) -> IngestEvent | null, served at POST /v1/webhooks/:id) or a custom Hatchet task in src/workflows/ passed as extraWorkflows (NOT workflows) to createWorker, including the idempotent batched expand→migrate→contract backfill pattern.
3
+ description: Use when adding an inbound webhook source in src/webhook-sources/ (defineWebhookSource — auth as a match|signature discriminated union, optional Zod schema, transform(payload, ctx) -> IngestEvent | null, served at POST /v1/webhooks/:id), reaching for a built-in integration preset (Clerk/Supabase/Stripe/Segment), or a custom Hatchet task in src/workflows/ passed as extraWorkflows (NOT workflows) to createWorker, including the idempotent batched expand→migrate→contract backfill pattern. Outbound signed webhooks are managed separately (hogsend webhooks CLI / hs.webhooks).
4
4
  license: MIT
5
5
  metadata:
6
6
  author: withSeismic
@@ -25,10 +25,20 @@ Relative imports use the ESM `.js` extension.
25
25
 
26
26
  - **`defineWebhookSource({ meta, auth, schema?, transform })`** (from
27
27
  `@hogsend/engine`) — declares one source served at `POST /v1/webhooks/:id`.
28
- `auth` matches a request header against an env secret; `schema` is an optional
29
- Zod validator; `transform(payload, ctx)` returns an `IngestEvent | null`
30
- (`null` = accept-and-skip). Register sources in `src/webhook-sources/index.ts`
31
- and pass them to `createApp(client, { webhookSources })` in `src/index.ts`.
28
+ `auth` is a **discriminated union on `type`**: `"match"` (shared-secret
29
+ equality against a header/`Authorization: Bearer`; OPEN when the secret is
30
+ unset) or `"signature"` (`scheme: "svix" | "stripe" | "hmac-hex"`, with an
31
+ `envKey`, optional `header`/`fallbackMatchHeader`; FAILS CLOSED with 401 when
32
+ the secret is unset). `schema` is an optional Zod validator; `transform(payload,
33
+ ctx)` returns an `IngestEvent | null` (`null` = accept-and-skip). Register
34
+ sources in `src/webhook-sources/index.ts` and pass them to
35
+ `createApp(client, { webhookSources })` in `src/index.ts`.
36
+ - **Built-in integration presets** — the engine ships four ready-made inbound
37
+ sources (Clerk, Supabase, Stripe, Segment) served at
38
+ `POST /v1/webhooks/{clerk,supabase,stripe,segment}` with no code to write. Each
39
+ mounts only when its secret env var is set AND `ENABLED_WEBHOOK_PRESETS`
40
+ allows it (`"*"`/absent = auto, a csv of ids = exactly those, `"none"` = off).
41
+ Defining your own source with the SAME id overrides the preset (you win).
32
42
  - **`IngestEvent`** — the shape `transform` must return:
33
43
  `{ event, userId, userEmail, properties, idempotencyKey? }`. The route feeds it
34
44
  straight into `ingestEvent()`, so a webhook can enroll users into journeys.
@@ -66,3 +76,20 @@ Relative imports use the ESM `.js` extension.
66
76
  helpers.
67
77
  - To verify a webhook or task against a running instance (events landing,
68
78
  contacts upserted, journeys firing), see the **hogsend-cli** skill.
79
+ - **Inbound vs outbound:** this skill is about *inbound* sources (HTTP → engine).
80
+ The engine also emits an *outbound* event stream (`contact.*`, `email.*`,
81
+ `journey.completed`, `bucket.*`). Two halves:
82
+ - **Subscriber endpoints** — manage the `webhook_endpoints` rows with
83
+ `hogsend webhooks …` (hogsend-cli skill) or `hs.webhooks.*` (hogsend-client-sdk
84
+ skill); verify signed deliveries with `verifyHogsendWebhook`. `create`/`update`
85
+ now take a `kind` + `config`: the default `kind="webhook"` is the byte-identical
86
+ signed `whsec_` POST (returns a one-time `secret`); a keyed destination
87
+ (`kind="posthog"|"segment"|"slack"|…`) carries its credentials in `config`
88
+ (e.g. `{ apiKey }`) and returns NO secret. Same durable retry/backoff/DLQ spine
89
+ either way — `kind` just selects the delivery-time transform.
90
+ - **Code-defined destinations** — author a delivery-time transform for a new
91
+ fan-out TARGET (a CRM, a warehouse, a custom shape) with `defineDestination()`
92
+ in `src/destinations/`. This is the symmetric twin of `defineWebhookSource`
93
+ on the OUTBOUND side. → the **hogsend-authoring-destinations** skill. NOTE:
94
+ outbound is no longer "just code in a journey" — for event fan-out, reach for
95
+ a destination, not a per-step integration call.
@@ -18,19 +18,40 @@ You write the source files; the engine owns the route. Edit only
18
18
  | `meta.id` | `string` | The `:sourceId` segment in the URL. Keep it URL-safe. |
19
19
  | `meta.name` | `string` | Human label. |
20
20
  | `meta.description?` | `string` | Optional. |
21
- | `auth.header` | `string` | Request header carrying the shared secret. |
22
- | `auth.envKey` | `string` | Env var holding the expected secret value. |
23
- | `auth.type` | `"match"` | Only mode today: header value must equal the env value. |
21
+ | `auth` | discriminated union on `type` | `"match"` or `"signature"` see below. |
24
22
  | `schema?` | `z.ZodSchema<T>` | Optional Zod validator; on success `payload` is typed `T`. |
25
23
  | `transform(payload, ctx)` | `=> Promise<IngestEvent \| null>` | Map payload → event. Return `null` to accept-and-skip. |
26
24
 
27
- ### Auth behaviour (important)
25
+ ### Auth: a discriminated union on `type`
28
26
 
29
- The route enforces auth **only when the env secret is set**. If
30
- `process.env[auth.envKey]` is empty/undefined the source is treated as **open**
31
- (no auth). When the secret is present, the request must send it either in
32
- `auth.header` or as `Authorization: Bearer <secret>`; otherwise the route returns
33
- `401`. Always set the env secret in any non-local environment.
27
+ `auth` is a discriminated union pick the variant that matches your provider:
28
+
29
+ ```ts
30
+ // "match" plain shared-secret equality (the PostHog scaffold source uses this)
31
+ auth: { type: "match"; header: string; envKey: string }
32
+
33
+ // "signature" — provider HMAC verification over the EXACT raw body bytes
34
+ auth: {
35
+ type: "signature";
36
+ scheme: "svix" | "stripe" | "hmac-hex";
37
+ envKey: string;
38
+ header?: string; // the signature header to read
39
+ fallbackMatchHeader?: string; // e.g. Supabase's plain x-supabase-webhook-secret
40
+ verify?(args): boolean | Promise<boolean>; // optional override of the scheme
41
+ }
42
+ ```
43
+
44
+ **Auth behaviour (important — the two variants differ on the unset-secret case):**
45
+
46
+ - **`"match"`** enforces auth **only when the env secret is set**. If
47
+ `process.env[envKey]` is empty/undefined the source is treated as **open** (no
48
+ auth). When the secret is present, the request must send it in `header` or as
49
+ `Authorization: Bearer <secret>`, else the route returns `401`. Always set the
50
+ secret in any non-local environment.
51
+ - **`"signature"`** **FAILS CLOSED** — when its secret is unset the route returns
52
+ `401` and never reaches `transform`. The route reads the EXACT raw body once
53
+ and verifies the HMAC (Svix / Stripe `t=…,v1=…` with 5-min tolerance / generic
54
+ hex HMAC) over those bytes. The raw bytes are also exposed as `ctx.rawBody`.
34
55
 
35
56
  ### Validation
36
57
 
@@ -52,9 +73,11 @@ interface IngestEvent {
52
73
  }
53
74
  ```
54
75
 
55
- `ctx` is `{ db, logger }` — a Drizzle `Database` and the engine logger — for
56
- lookups/diagnostics inside the transform. It does **not** carry `hatchet` or the
57
- registry; those are applied by the route when it calls `ingestEvent`.
76
+ `ctx` is `{ db, logger, rawBody?, headers? }` — a Drizzle `Database`, the engine
77
+ logger, and (populated by the route) the EXACT raw request body bytes and the
78
+ lowercased request headers, for lookups/diagnostics or provider-specific raw
79
+ access inside the transform. It does **not** carry `hatchet` or the registry;
80
+ those are applied by the route when it calls `ingestEvent`.
58
81
 
59
82
  Notes that match the engine's behaviour:
60
83
 
@@ -160,6 +183,28 @@ const app = createApp(client, { webhookSources });
160
183
 
161
184
  That's it. Your source is now live at `POST /v1/webhooks/stripe`.
162
185
 
186
+ ## Built-in integration presets (no code)
187
+
188
+ Before writing a source, check whether the engine already ships one. Four
189
+ presets are built into `@hogsend/engine` and served with no consumer code:
190
+
191
+ | Preset | Route | Scheme | Secret env var |
192
+ |--------|-------|--------|----------------|
193
+ | `clerk` | `POST /v1/webhooks/clerk` | `svix` | `CLERK_WEBHOOK_SECRET` |
194
+ | `supabase` | `POST /v1/webhooks/supabase` | `svix` (+ plain `x-supabase-webhook-secret` fallback) | `SUPABASE_WEBHOOK_SECRET` |
195
+ | `stripe` | `POST /v1/webhooks/stripe` | `stripe` | `STRIPE_WEBHOOK_SECRET` |
196
+ | `segment` | `POST /v1/webhooks/segment` | `hmac-hex` | `SEGMENT_WEBHOOK_SECRET` |
197
+
198
+ A preset mounts only when **both** its secret env var is set **and**
199
+ `ENABLED_WEBHOOK_PRESETS` allows it: `"*"`/absent = auto (every preset whose
200
+ secret is set), a comma-separated list of ids = exactly those (still requires the
201
+ secret), `"none"` = all off. A preset with no secret is never mounted (signature
202
+ sources fail closed). Defining your own `defineWebhookSource` with the SAME id
203
+ **overrides** the preset — the consumer always wins. Each preset's exact
204
+ provider-event → Hogsend-event mapping (and the `contactProperties` vs
205
+ `eventProperties` split) lives in its source file under
206
+ `packages/engine/src/webhook-sources/presets/`.
207
+
163
208
  ## Authoring a new source — checklist
164
209
 
165
210
  1. Create `src/webhook-sources/<id>.ts` exporting a `defineWebhookSource({...})`.
@@ -12,6 +12,7 @@ import { statsCommand } from "./stats.js";
12
12
  import { studioCommand } from "./studio.js";
13
13
  import type { Command } from "./types.js";
14
14
  import { upgradeCommand } from "./upgrade.js";
15
+ import { webhooksCommand } from "./webhooks.js";
15
16
 
16
17
  /**
17
18
  * The command registry. The router (src/bin.ts) matches the leading argv token
@@ -29,6 +30,7 @@ export const commands: Command[] = [
29
30
  eventsCommand,
30
31
  emailsCommand,
31
32
  campaignsCommand,
33
+ webhooksCommand,
32
34
  studioCommand,
33
35
  setupCommand,
34
36
  skillsCommand,