@hogsend/cli 0.7.0 → 0.8.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/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,7 +32,7 @@
32
32
  "tsup": "^8.5.1",
33
33
  "tsx": "^4.22.4",
34
34
  "vitest": "^4.1.7",
35
- "@hogsend/studio": "^0.7.0",
35
+ "@hogsend/studio": "^0.8.0",
36
36
  "@repo/typescript-config": "0.0.0"
37
37
  },
38
38
  "engines": {
@@ -74,6 +74,7 @@ Most commands READ (admin API). A handful WRITE through the data plane — marke
74
74
  | `hogsend events <userId>` | Raw event stream for one user (READ — `<userId>` stays the read path). |
75
75
  | `hogsend events send <name>` | **(write)** Push an event → `POST /v1/events`. `--email`/`--user-id` (≥1 required), `--prop`/`--props` (event props), `--contact-prop`/`--contact-props` (contact props), `--list`/`--unlist`, `--idempotency-key`, `--timestamp`. |
76
76
  | `hogsend emails send <template>` | **(write)** Send a transactional email → `POST /v1/emails`. `--to`/`--user-id` (≥1 required), `--prop`/`--props`, `--subject`, `--from`, `--reply-to`, `--category`, `--idempotency-key`, `--skip-preference-check` (needs full-admin). |
77
+ | `hogsend webhooks list/get/create/update/delete/rotate-secret/test` | Manage **outbound** signed webhook endpoints (the event stream Hogsend emits to your URLs) → `/v1/admin/webhooks`. Needs the **admin key**, not the data key. `create --url <url>` + repeatable `--event <type>` or `--all-events`; the signing secret prints ONCE on `create` + `rotate-secret`. |
77
78
  | `hogsend skills list/add` | Manage these bundled agent skills. |
78
79
  | `hogsend upgrade` | Bump `@hogsend/*` deps to latest + refresh vendored skills. |
79
80
  | `hogsend setup` | Interactive LOCAL onboarding (docker, secret, migrate). |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: hogsend-client-sdk
3
- description: Use when calling Hogsend from your own product/app code (a signup handler, a billing webhook, a cron) via the @hogsend/client SDK + public data-plane API — new Hogsend({ baseUrl, apiKey }), then contacts.upsert/find/delete, events.send (alias .track), emails.send, lists.list/subscribe/unsubscribe. Teaches the contactProperties-vs-eventProperties split on POST /v1/events, the ingest-scoped HOGSEND_API_KEY, the 202 + listsError warning, and HogsendAPIError/RateLimitError. NOT for use inside a journey (there, use sendEmail()/ctx.trigger()). The scaffold ships a preconfigured `hs` at src/lib/hogsend.ts.
3
+ description: Use when calling Hogsend from your own product/app code (a signup handler, a billing webhook, a cron) via the @hogsend/client SDK + public data-plane API — new Hogsend({ baseUrl, apiKey }), then contacts.upsert/find/delete, events.send (alias .track), emails.send, lists.list/subscribe/unsubscribe, webhooks.create/list/get/update/delete/rotateSecret/sendTest (ADMIN plane — needs a full-admin key), and verifyHogsendWebhook for the subscriber side. Teaches the contactProperties-vs-eventProperties split on POST /v1/events, the ingest-scoped HOGSEND_API_KEY, the 202 + listsError warning, and HogsendAPIError/RateLimitError. NOT for use inside a journey (there, use sendEmail()/ctx.trigger()). The scaffold ships a preconfigured `hs` at src/lib/hogsend.ts.
4
4
  license: MIT
5
5
  metadata:
6
6
  author: withSeismic
@@ -103,6 +103,45 @@ await hs.emails.send({ // POST /v1/emails
103
103
  await hs.lists.list(); // GET /v1/lists -> ListSummary[]
104
104
  await hs.lists.subscribe({ list: "newsletter", email: "ada@example.com" });
105
105
  await hs.lists.unsubscribe({ list: "newsletter", userId: "u_1" });
106
+
107
+ // Webhooks (ADMIN plane — needs a full-admin apiKey, NOT the ingest key) ---
108
+ await hs.webhooks.create({ // POST /v1/admin/webhooks
109
+ url: "https://your.app/hooks",
110
+ eventTypes: ["contact.created", "email.sent"],
111
+ }); // -> endpoint incl. full `secret` (shown ONCE)
112
+ await hs.webhooks.list(); // -> WebhookEndpoint[]
113
+ await hs.webhooks.rotateSecret("we_123"); // -> { id, secret, secretPrefix } (secret ONCE)
114
+ ```
115
+
116
+ ## `hs.webhooks` — outbound endpoints (DIFFERENT plane + key)
117
+
118
+ `hs.webhooks.*` manages the **outbound** signed event stream Hogsend emits to
119
+ your URLs (`contact.*`, `email.*`, `journey.completed`, `bucket.*`). Unlike every
120
+ other resource above, it targets the **ADMIN plane** (`/v1/admin/webhooks`) and
121
+ **requires a full-admin `apiKey`** — a leaked ingest key must never register an
122
+ exfiltration endpoint. Construct a separate `Hogsend` instance with an admin key
123
+ if your data-plane `hs` only holds an ingest key.
124
+
125
+ `create`/`list`/`get`/`update`/`delete`/`rotateSecret`/`sendTest`. The full
126
+ signing secret (`whsec_…`) is returned **only once** — on `create` and
127
+ `rotateSecret`; `list`/`get` only expose `secretPrefix`. Store it on create.
128
+
129
+ **Subscriber side:** in the handler that RECEIVES Hogsend's signed POSTs, call
130
+ `verifyHogsendWebhook({ payload, headers, secret })` (also exported from
131
+ `@hogsend/client`). Pass the **raw request body bytes** (never a re-stringified
132
+ object); it returns the parsed `{ id, type, timestamp, data }` envelope and
133
+ THROWS on a bad/missing signature or a timestamp outside the 5-minute tolerance.
134
+ Deliveries are at-least-once — dedupe on the `Webhook-Id` header.
135
+
136
+ ```ts
137
+ import { verifyHogsendWebhook } from "@hogsend/client";
138
+
139
+ const event = verifyHogsendWebhook({
140
+ payload: rawBody, // the EXACT bytes Hogsend signed
141
+ headers: req.headers,
142
+ secret: process.env.HOGSEND_WEBHOOK_SECRET!, // whsec_… from create / rotate
143
+ });
144
+ // switch on event.type …
106
145
  ```
107
146
 
108
147
  ## `eventProperties` vs `contactProperties` (the split that trips people up)
@@ -157,6 +157,105 @@ For how `subscribe`/`unsubscribe` interact with a list's `defaultOptIn` polarity
157
157
  (opt-in needs an exact `true`, opt-out is blocked only on an exact `false`), see
158
158
  the hogsend-authoring-lists skill.
159
159
 
160
+ ## `hs.webhooks` (ADMIN plane — full-admin key required)
161
+
162
+ Manage **outbound** webhook endpoints (the Svix-style signed event stream Hogsend
163
+ emits to subscriber URLs). Every method targets `/v1/admin/webhooks` and requires
164
+ a **full-admin** `apiKey` — NOT the ingest data key the resources above use.
165
+ Signing-secret management is the same trust class as API-key management.
166
+
167
+ ### `webhooks.create(input) → CreatedWebhookEndpoint`
168
+
169
+ `POST /v1/admin/webhooks`. Register an endpoint subscribed to one or more of the
170
+ 12 outbound event types. Returns the endpoint INCLUDING the full signing
171
+ `secret` (`whsec_…`) — shown ONLY here and on `rotateSecret`. Store it now.
172
+
173
+ ```ts
174
+ type CreateWebhookInput = {
175
+ url: string;
176
+ eventTypes: OutboundEventType[]; // contact.* | email.* | journey.completed | bucket.*
177
+ description?: string;
178
+ disabled?: boolean;
179
+ };
180
+
181
+ interface WebhookEndpoint {
182
+ id: string; // we_…
183
+ url: string;
184
+ description: string | null;
185
+ eventTypes: OutboundEventType[];
186
+ secretPrefix: string; // first 12 chars, e.g. whsec_AbCd
187
+ status: "enabled" | "disabled";
188
+ organizationId: string | null;
189
+ lastDeliveryAt: string | null; // ISO
190
+ createdAt: string; // ISO
191
+ updatedAt: string; // ISO
192
+ }
193
+
194
+ type CreatedWebhookEndpoint = WebhookEndpoint & { secret: string }; // full secret ONCE
195
+ ```
196
+
197
+ ### `webhooks.list(opts?) → WebhookEndpoint[]`
198
+
199
+ `GET /v1/admin/webhooks`. Newest first; `opts` = `{ limit?, offset?,
200
+ includeDisabled? }`. Returns the endpoints array (unwrapped from the
201
+ `{ endpoints, total, limit, offset }` envelope). No `secret` — `secretPrefix` only.
202
+
203
+ ### `webhooks.get(id) → WebhookEndpoint`
204
+
205
+ `GET /v1/admin/webhooks/{id}`. One endpoint (404 → `HogsendAPIError`). No secret.
206
+
207
+ ### `webhooks.update(id, input) → WebhookEndpoint`
208
+
209
+ `PATCH /v1/admin/webhooks/{id}`. Only the provided fields change;
210
+ `description: null` clears it. Does NOT return or rotate the secret.
211
+
212
+ ```ts
213
+ type UpdateWebhookInput = {
214
+ url?: string;
215
+ eventTypes?: OutboundEventType[];
216
+ description?: string | null;
217
+ disabled?: boolean;
218
+ };
219
+ ```
220
+
221
+ ### `webhooks.delete(id) → { deleted }`
222
+
223
+ `DELETE /v1/admin/webhooks/{id}`. Hard-delete; cascade drops its deliveries.
224
+
225
+ ### `webhooks.rotateSecret(id) → { id, secret, secretPrefix }`
226
+
227
+ `POST /v1/admin/webhooks/{id}/rotate-secret`. Hard cutover — the OLD secret is
228
+ invalidated immediately; in-flight retries re-sign with the new one. The new
229
+ `secret` is returned ONCE. Update every subscriber.
230
+
231
+ ### `webhooks.sendTest(id) → { enqueued, eventType: "webhook.test" }`
232
+
233
+ `POST /v1/admin/webhooks/{id}/test`. Enqueues an out-of-band `webhook.test`
234
+ delivery, sent regardless of the endpoint's subscribed `eventTypes`.
235
+
236
+ ## `verifyHogsendWebhook(opts)` — the subscriber side
237
+
238
+ A standalone helper exported from `@hogsend/client` (not on the `Hogsend`
239
+ instance) for the handler that RECEIVES Hogsend's signed POSTs.
240
+
241
+ ```ts
242
+ import { verifyHogsendWebhook } from "@hogsend/client";
243
+
244
+ const event = verifyHogsendWebhook({
245
+ payload: rawBody, // the EXACT raw request body bytes — never a re-stringified object
246
+ headers: req.headers,
247
+ secret: "whsec_…", // the endpoint secret from create / rotateSecret
248
+ });
249
+ // event === { id, type, timestamp, data }
250
+ ```
251
+
252
+ - Returns the parsed envelope (`{ id, type, timestamp, data }`) on success.
253
+ - **THROWS** on a bad signature, a missing signature header, or a timestamp
254
+ outside the 5-minute tolerance — wrap in `try/catch` and reply `401` on throw.
255
+ - Accepts both the `Webhook-*` and `svix-*` header aliases (case-insensitive).
256
+ Uses svix when available, with a pure `node:crypto` HMAC-SHA256 fallback.
257
+ - Deliveries are **at-least-once** — dedupe on the `Webhook-Id` (`event.id`).
258
+
160
259
  ## Construction options (recap)
161
260
 
162
261
  ```ts
@@ -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,8 @@ 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* signed event stream (`contact.*`,
81
+ `email.*`, `journey.completed`, `bucket.*`) to subscriber URLs — manage those
82
+ endpoints with `hogsend webhooks …` (hogsend-cli skill) or `hs.webhooks.*`
83
+ (hogsend-client-sdk skill), and verify deliveries with `verifyHogsendWebhook`.
@@ -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,