Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 58 additions & 92 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,130 +1,96 @@
# @corbits/webhooks

Inbound HTTP → check a **tenant-owned** vault secret → fire a **live** `onTrigger` as that deployment's **run principal**.
Inbound HTTP → check a tenant-owned vault secret → fire a live `onTrigger` as that deployment's run principal. The signing cred is only how you got in. The workflow runs as `deriveRunPrincipalId(tenantId, runId)` — Interchange's mail-triggered grant path, not the credential owner. Credentials stay Interchange's (`POST /credentials`, `credential:*`); this package adds `POST /api/hooks`.

The signing cred is only how you got in. The workflow runs as `deriveRunPrincipalId(tenantId, runId)` — Interchange's mail-triggered grant path, not the credential owner.
## Runtime support

Credentials, grants, and authz are Interchange's (`POST /credentials`, `credential:*`). This package only adds `POST /api/hooks`.
`package.json` does not declare `engines`. The published export is TypeScript source (`./src/index.ts`); Bun consumes it directly. Native Node does not load this extensionless TypeScript source as-is.

---
## Quickstart

## 1. Workflow listens on mail
```sh
npm add @corbits/webhooks
pnpm add @corbits/webhooks
yarn add @corbits/webhooks
bun add @corbits/webhooks
```

`installWebhooks(opts)` mounts `POST /api/hooks` directly on the host app. Every field of `opts` is a host responsibility — the same four a hub already has on hand from its own boot sequence (a drizzle handle, its credential cipher, its principal key store, and its mail router):

| `opts` | Type | What the host provides |
| --- | --- | --- |
| `app` | `{ route(path, handler): unknown }` | Any Hono app; `installWebhooks` mounts `/api/hooks` on it. |
| `db` | `DB["db"]` (from `@intx/db`) | The host's existing drizzle handle. |
| `credentialCipher` | `CredentialCipher` (from `@intx/types`) | Decrypts the vault secret a hook's credential carries. |
| `principalKeyStore` | `PrincipalKeyStore` (from `@intx/db`) | Signs the trigger mail as the run's own principal. |
| `router` | `HookMailRouter` | Delivers the trigger mail once a hook fires. A hub backs this with its live sidecar router. |

The function below compiles against this package's real entry point, with every host-owned dependency passed in by its own published type — no in-process store standing in for a hub's database, cipher, key store, or router:

```ts
import type { Hono } from "hono";
import type { DB, PrincipalKeyStore } from "@intx/db";
import type { CredentialCipher } from "@intx/types";
import { installWebhooks, type HookMailRouter } from "@corbits/webhooks";

export async function mountWebhooks(
app: Hono,
db: DB["db"],
credentialCipher: CredentialCipher,
principalKeyStore: PrincipalKeyStore,
router: HookMailRouter,
): Promise<void> {
await installWebhooks({ app, db, credentialCipher, principalKeyStore, router });
}
```

Bot tokens for media and chat integrations are separate `credentialBindings` — not this signing secret.

### Lower-level: `createRunTriggerDeliverer` and `createTenantSystemSender`

Deploy a workflow with `onTrigger({ on: { type: "mail", to } })`. After `ez push` it has a live address (`run_…@domain`).
`installWebhooks` builds its own deliverer internally from these two exports; a host reaches for them directly only when it is driving trigger mail outside a hook — for example `@corbits/cron`'s ticker points a due schedule at the very same `createRunTriggerDeliverer`, given a `HookMailRouter` and a `PrincipalKeyStore`, so cron and webhooks fire through one system-trigger path. `createTenantSystemSender({ db, principalKeyStore })` gives that deliverer a durable per-tenant identity (`<senderLocalPart>@domain`) the trigger mail is signed and authenticated as. Match a delivery that couldn't route with `isRunTriggerUnroutable(error)`, which narrows to `{ code, address, runId }` — `code` is `RUN_GRANTS_NOT_ROUTABLE` or `RUN_MAIL_NOT_ROUTABLE`.

---
## How it works

## 2. Create the webhook (a credential)
`installWebhooks` mounts `POST /api/hooks` and builds a durable per-tenant system sender (`webhook@domain`) so the trigger mail's `From` verifies. Match unroutable deliveries with `isRunTriggerUnroutable` so callers that speak the `MailDeliverer` shape (e.g. `@corbits/cron`) see the same `address` and `runId`.

Setting a hook **is** creating a tenant credential. That write is already gated by `credential:*` / `create`.
Setting a hook is creating a tenant org credential (`principalId` null) on a workflow deployed with `onTrigger({ on: { type: "mail", to } })`. After deploy it has a live address (`run_…@domain`):

```bash
curl -X POST "$HUB/api/tenants/$TNT/providers" \
-H "content-type: application/json" -H "cookie: $COOKIE" \
-d '{"name":"webhooks","plugin":"api_key"}'
# → { "id": "prv_…" }

curl -X POST "$HUB/api/tenants/$TNT/credentials" \
-H "content-type: application/json" -H "cookie: $COOKIE" \
-d '{
"name": "slack",
"providerId": "prv_…",
"type": "api_key",
"secret": "YOUR_SIGNING_SECRET",
"secret": "[redacted: looks like a credential]",
"metadata": {
"webhook": {
"verify": "slack",
"workflow": "jimmy"
}
}
}'
# → { "id": "crd_…" }
```

| `metadata.webhook` | |
|---|---|
| `verify` | `bearer` \| `standard-webhooks` \| `slack` (required; there is no `none`) |
| `workflow` | Live deployment whose definition or asset name matches |
| `to` | Live run **address in this tenant** (`run_…@domain`). Foreign addresses are ignored. |

Org credentials only (`principalId` null). Personal creds are not ingress keys. Rotate with `PATCH` on that credential.

---

## 3. Point the sender at the hub

Prefer the credential id (unguessable, unique):

```
POST $HUB/api/hooks/crd_…
```

Name is tenant-scoped — put the tenant in the path (or `x-tenant-id`):
`metadata.webhook.verify` is `bearer` | `standard-webhooks` | `slack` (required; there is no `none`). `workflow` is a live deployment whose definition or asset name matches; `to` is a live run address in this tenant. Prefer the credential id (`POST $HUB/api/hooks/crd_…`); the name form is tenant-scoped (`POST $HUB/api/hooks/$TNT/slack`). `verify: "slack"` echoes Slack `url_verification` (no mail).

```
POST $HUB/api/hooks/$TNT/slack
```

```
HMAC with vault "slack"
→ live jimmy run in that tenant
→ onTrigger mail
```
## Development

`verify: "slack"` echoes Slack `url_verification` (no mail).

---

## Host (once)

```ts
await installWebhooks({
app,
db,
credentialCipher,
principalKeyStore,
router: sidecarRouter,
});
```sh
git clone https://github.com/corbitsdev/webhooks.git
cd webhooks
bun install
bun run typecheck
bun run test
```

Jimmy's Giphy / Slack *bot token* are separate `credentialBindings` — not this signing secret.

## System sender identity

A system trigger (webhook, cron) is signed by a **durable per-tenant sender**,
not a throwaway key: one `kind: "user"` principal per tenant and local part
(`webhook@domain`, `cron@domain`), minted on first use with its key in the
principal key store. That address is the mail's `From`, the
`authenticatedSender` on `routeMail`, and the `senderIdentities` entry
co-delivered on the run's grants barrier — so the recipient verifies the
signature against the key the hub vouches for. A throwaway key resolves to
`unknown`/`invalid`, which the default admission policy rejects.

`installWebhooks` builds it. A host wiring `createRunTriggerDeliverer`
directly (cron) passes it too:

```ts
createRunTriggerDeliverer({
router,
materialize,
tenantDomain,
senderLocalPart: "cron",
systemSender: createTenantSystemSender({ db, principalKeyStore }),
});
```

## Unroutable run triggers

When the deployment address has no live socket and no disconnect queue —
its sidecar is gone — the deliverer rejects with a
`RunTriggerUnroutableError` (`code` `run_grants_not_routable` /
`run_mail_not_routable`) carrying the dead run's `address` and `runId`
instead of a bare string, so the caller can report a real failure naming
the run and settle it. Match it structurally with
`isRunTriggerUnroutable(error)` — not `instanceof` — so dependency-free
callers (e.g. `@corbits/cron`, which speaks to the deliverer through the
`MailDeliverer` shape alone) match the same contract.
`bun run test` is `bun test ./src`.

## License

LGPL-2.1
LGPL-2.1-only.