AGENTS.md
+32 −10
https://raw.githubusercontent.com/kroqdotdev/webhooks-cc/HEAD/AGENTS.md
− cd apps/cli-rs && cargo test− Web (3000), receiver (3001, plus a private mail listener on 3002), the SMTP listener for `mailhooks.cc` on its own box (25), a self-hosted Supabase instance (Postgres, Auth, Realtime), a Cloudflare Worker for outbound notifications, and optional Redis for distributed rate limiting.− - **Teams are billed per team.** A Polar seat subscription on the `teams` row buys the member cap and a pooled quota of seats x 100,000 requests per 30 days. `users.plan` stays free or pro and does not gate team access. `capture_webhook()` bills an endpoint shared with an active team against that team and stamps `requests.team_id`; team-billed requests keep 31-day retention regardless of the owner's plan. Each team gets its own Polar customer of type team: Polar allows one customer per email per− - **The visual-style A/B split is a local coin flip, PostHog only analyses it.** posthog-js resolves feature flags after first paint, so a flag-driven style would re-skin the page in front of the visitor. `appearanceBootstrapScript()` in `apps/web/lib/ui-style.ts` assigns the variant before paint and the app reports it to PostHog as `$feature/ui-style` plus one `$feature_flag_called` exposure per session, which is what the Experiments UI reads. PostHog names experiment variants `control` and `te− - **Email is an ordinary request.** Email capture lands mail for `{slug}@mailhooks.cc` as a request with `kind = 'email'`, through the same `capture_webhook()` call, quota, billing period and counters as HTTP. The SMTP host (`apps/mx-rs`) runs on its own box, so a public MX record never exposes the production IP; it refuses unknown, guest, over-quota and oversize mail before accepting it and calls the receiver's private, HMAC-signed mail listener, which must only ever be reachable on the private− - **Forwarding is a queue the web app drains.** `capture_webhook()` queues a delivery row (table `email_deliveries`, which holds HTTP requests too since migration 00058; the old names stay because a rename would need compatibility views across a deploy) in the capture's own transaction for each kind the endpoint forwards, so a captured request is never lost before it is queued. The worker runs in the web app, not the receiver (`lib/forwarding/worker.ts`, started from `instrumentation.ts`), becau− - **Outbound notifications hide the origin IP** by going through the Cloudflare Worker when `NOTIFY_PROXY_URL` is set; otherwise the receiver delivers directly with SSRF-safe DNS pinning.− - **The TypeScript SDK is the reference for every SDK.** `packages/sdk/src/__tests__/conformance.test.ts` writes JSON vectors (signing, verification, forwarding signatures, matchers, durations) to `packages/sdk/conformance/`, and SDKs in other languages run them in their own CI, so their behaviour cannot drift; the Python and Go SDKs (`packages/sdk-python`, `packages/sdk-go`) read them straight from that directory. Go takes `time.Duration` rather than duration strings, so it skips `durations.jso− - **Design system.** The UI ships in two styles that each user picks next to the light and dark switch: classic (neobrutalist, Space Grotesk) and clean (close to stock shadcn/ui, Geist). Both share one component tree; `data-style` on `<html>` swaps the tokens, independently of light and dark mode. Write classes with the style-aware vocabulary documented at the top of `apps/web/app/globals.css` (`border-strong`, `border-line`, `shadow-raised`, `caps`, the `ui-*` classes) instead of raw `border-2 − Split model. AGPL-3.0: `apps/web`, `apps/receiver-rs`, `apps/mx-rs`, `supabase/`. MIT: `apps/cli-rs`, `packages/sdk`, `packages/sdk-python`, `packages/sdk-go`, `packages/mcp`, `plugins/`.+ | `apps/parser-rs/` | Rust attachment parser `webhooks-parser` (CSV, TSV, XLSX, XLS, ODS, PDF text, text): a sandboxed child process of the web app's parse worker. |+ | `packages/zapier-app/` | The Zapier app (CommonJS, `zapier-platform-core`): New Email as a REST hook on destinations, actions, a search. Published with the Zapier CLI. |+ | `packages/make-app/` | The Make custom app as IML definitions (base, connection, webhook, modules, RPC). Uploaded through Make. |+ cd apps/cli-rs && cargo test # tests that reach a server (integration, and smoke's list/usage/lifecycle) skip unless WHK_API_URL names one; without it they would use webhooks.cc and this machine's saved login+ cd apps/parser-rs && cargo test && cargo clippy --all-targets -- -D warnings # cargo build --release for the web app's parse worker+ Web (3000), receiver (3001, plus a private mail listener on 3002), the SMTP listener for `mailhooks.cc` on its own box (25), a self-hosted Supabase instance (Postgres, Auth, Realtime), a Cloudflare Worker that outbound deliveries go through, and optional Redis for the web app's distributed rate limiting.+ - **Teams are billed per team.** A Polar seat subscription on the `teams` row buys the member cap and a pooled quota. It is on one of two plan products, both from 2 seats (Polar's first seat tier; teams subscribed with one seat before that keep it until they change it, so the app checks the minimum too): Teams (seats x 100,000 requests per 30 days, monthly only) or Team Business (migration 00083: Business's requests, parsed pages and AI assists per seat, `plan_entitlements.per_seat`, which `acco+ - **Extra requests are add-on subscriptions and orders on the plan's Polar customer.** Pro and Business accounts and subscribed teams buy 25,000-request blocks three ways (migration 00065, `lib/supabase/capacity-billing.ts`): monthly blocks (a unit-priced subscription), one-time blocks (unit-priced orders, never expire) and overage (a subscription with only a metered price). A capture spends, in order, the allowance (`request_limit + block_requests`, still one conditional update), then one-time + - **Refunds go through a queue a person can hold.** A Polar charge that bought nothing (a second plan or extra-domains subscription the webhook revokes, an add-on order paid for an account or team deleted meanwhile, extra domains with nothing left to add them to, a plan or team subscription whose checkout completed after its account or team was deleted) is recorded in `polar_refunds` (migration 00086, `recordRefund()` in `lib/billing/refunds.ts`), one row per order or subscription, and the web a+ - **Parsed pages are sold like requests, and only with Business.** Business and Team Business buy 1,000-page blocks the same three ways (migration 00070; the `resource` of each add-on in `capacity-billing.ts`, meter event `page.overage`), spent after the plan's pages by `take_parsed_pages()`. They need the plan that allows them: a plan change away from Business or Team Business sets them to end with the period, and they cannot be started or kept while that change waits, because they would renew + - **Custom inbound domains are routed in the database, and extra ones are add-ons.** A Business account (or a Team Business team) adds its own domain (migration 00072): a TXT record `_mailhooks.<domain>` with its token and MX records that all point at `MAIL_MX_HOST` verify it, the web app's checker (`lib/supabase/inbound-domains.ts`, public resolvers from `DOMAIN_DNS_SERVERS` so a customer's nameservers never see our host) checks again every day and lapses a domain after three failed checks, cou+ - **Zapier and Make are REST hooks on destinations.** Both apps (`packages/zapier-app`, `packages/make-app`) connect with an API key and add a JSON destination (email only) to the chosen endpoint when a Zap or scenario turns on, and delete it when it turns off, so they get the same signed `email.received` JSON, parsed fields included, as any destination, through the same queue, filters and retries; samples come from `POST /api/requests/{id}/render` with `format: "json"`, the same payload a hook + - **AI only writes patterns, and only when asked.** AI setup and repair (migration 00075, `lib/ai/`) sends emails a person picked (1 to 5 of the endpoint's own, subject, sender, recipients, text and headers, never attachments) to Anthropic (`claude-sonnet-5-5` through `@anthropic-ai/sdk`, structured output, server-side fallbacks; Sonnet because it matched Opus on a comparison of made-up sample sets at half the cost, while Haiku proposed patterns that found the wrong value yet passed the check) o+ - **File drops leave from the MX host.** A destination of format `file_drop` (migration 00074, Business) writes CSV, XML or JSON files to the owner's SFTP, FTP/FTPS or S3-compatible server; the forwarding worker delivers them like Sheets rows (one claim per destination, a file per request or `claim_drop_batch()` for everything waiting once the oldest is due, the destination's delay being the batch window). Every connection, FTP data connections included, goes through `openDropSocket()` in `lib/d+ - **Plan changes follow what Polar allows.** Business is a plan product like Pro ($29 monthly, $290 annual); the webhook reads the plan and interval from the product id (`planForProduct()`). Polar refuses `reset` proration for this organisation, so Pro to Business monthly applies at once with `invoice` (Polar charges the difference for the rest of the period) and the webhook grants the new plan's extra requests and pages in the same proportion (`request_limit`, and `prorated_pages` for the windo+ - **The visual style is chosen in the browser, before paint.** Clean is the default; Account > Appearance (signed in) switches a browser to classic or back, and the inline script from `appearanceBootstrapScript()` in `apps/web/lib/ui-style.ts` applies the stored pick to `<html>` before React loads, so a page never re-skins in front of the visitor. The pick is kept in localStorage, not on the account, so it stays with the browser across sign-outs. The earlier A/B split (PostHog experiment, `NEXT_+ - **Plans are rows, not names.** `plan_entitlements` (migration 00067) holds every limit and feature that differs between plans (free, pro, business) and team kinds (team, and team_business): requests, quota period, retention, parsed pages, layouts, AI assists, domains, file drops, add-on eligibility, annual billing. SQL functions read it instead of comparing plan names, and `lib/entitlements.ts` mirrors it; an integration test compares the two, so change them together. `users.period_start`/`per+ - **Retention is per account and per endpoint, deleted in small batches.** `cleanup_expired_requests()` (every 10 minutes, bounded batches through the existing indexes) deletes each account's personal rows past its plan's retention, team-billed rows past the team's, and an endpoint's rows past its own shorter `retention_days`; `cleanup_delivered_requests()` (every minute) does "deliver, then delete" (`delete_after_delivery`) once no delivery or parse is pending and the last one finished a few mi+ - **Captured email lives in object storage when it is configured.** The receiver stores each email's exact message (`m/{request_id}/raw.eml`) and decoded attachments (`m/{request_id}/a/{i}`) in S3-compatible storage (`OBJECT_STORAGE_*`; in production a self-hosted SeaweedFS in Denmark, reached through Cloudflare as `storage.webhooks.cc`, so presigned links work from anywhere) before calling `capture_webhook()` with the request id it chose and the prefix; a failed upload answers transient, so the+ - **Layouts parse an email before it is delivered.** An endpoint's `layouts` (migration 00069) pick, by the rule language, how an email is read: fields, line items, attachment parsing. `capture_webhook()` queues a `request_parses` job instead of the deliveries for an email on an endpoint with layouts; the web app's parse worker (`lib/parsing/worker.ts`) leases jobs, runs the layout with RE2 and `apps/parser-rs` for attachments, and `complete_request_parse()` queues the deliveries (after five fai+ - **Email is an ordinary request.** Email capture lands mail for `{slug}@mailhooks.cc` as a request with `kind = 'email'`, through the same `capture_webhook()` call, quota, billing period and counters as HTTP. The SMTP host (`apps/mx-rs`) runs on its own box, so a public MX record never exposes the production IP; it refuses unknown, guest, over-quota and oversize mail before accepting it and calls the receiver's private, HMAC-signed mail listener, which must only ever be reachable on the private+ - **Forwarding is a queue the web app drains.** An endpoint sends to up to 10 rows of `endpoint_destinations` (migration 00063), each with its own kinds, filter, format, templates, throttle, delay and secret. `capture_webhook()` queues a delivery row (table `email_deliveries`, which holds HTTP requests too since migration 00058; the old names stay because a rename would need compatibility views across a deploy) in the capture's own transaction for each destination that takes the kind, so a captu+ - **One rule language and one placeholder syntax.** Response rules (which answer a sender gets) and forwarding filters share the conditions in `packages/sdk/src/rules.ts`; mock responses and forwarded payloads share the `{{ path | filter }}` placeholders in `packages/sdk/src/placeholders.ts`. The receiver evaluates rules and fills in mock responses in Rust (`handlers/rules.rs`, `placeholders.rs`, `values.rs`), the web app with the SDK; both run `packages/sdk/conformance/rules.json` and `placehol+ - **One app serves two sites.** webhooks.cc and mailhooks.cc (the mail-first front door) are the same Next app, accounts, endpoints and billing (`lib/site.ts`). The site comes from the Host: `proxy.ts` resolves it against the two configured origins only (`NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_MAILHOOKS_URL`, a build argument; unset means one site), passes it on in `x-whk-site`, lets mailhooks.cc answer only its own paths (the rest 308 to webhooks.cc) and keeps `app/mailhooks` off webhooks.cc; `next+ - **Staff, support tickets and the operations center** (migration 00087). Staff are the auth user ids in `staff`, inserted by hand with psql, never in a migration and never matched by email; a trigger on `users` keeps a staff account on Business without a subscription, so a revoked subscription or the period job never moves it. `/ops` (webhooks.cc only) and every `/api/ops` route answer 404 to anyone else, accept a session only, and need two-factor sign-in (`aal2`, `lib/staff.ts`); any account w+ - **Analytics: consent in the browser, accounts on the server.** posthog-js runs in cookieless mode until the visitor accepts in the cookie banner (`cookieless_mode: "on_reject"`, opted out by default, Do Not Track and GPC count as a decline), so nothing is stored in the browser without consent and a signed-in user is identified only after it; each site keeps its own choice, and the site switcher carries it over sealed in its grant and ticket (an accept only answers an unanswered banner, a decli+ - **Outbound deliveries hide the origin IP** by going through the Cloudflare Worker when `NOTIFY_PROXY_URL` is set; otherwise the web app delivers directly with SSRF-safe DNS pinning.+ - **Agents keep their registrations in one shared file.** The MCP server, the TypeScript, Python and Go SDKs and the CLI read and write `~/.config/whk/agent.json` (XDG, macOS and Windows rules, `WHK_AGENT_FILE` override; the CLI's `token.json` lives beside it), so a connection a human approved once survives restarts and works from every tool. The format and the rules (live slots, the cached access token, atomic 0600 writes, what `invalid_grant` removes) are in `packages/sdk/conformance/agent-cre+ - **The remote MCP server is the stdio server's tools behind OAuth.** `/mcp` (`app/mcp/route.ts`, `lib/mcp/hosted.ts`) runs `registerTools()` from `@webhooks-cc/mcp/tools` on the v2 MCP SDK (`@modelcontextprotocol/server`), statelessly, for the 2026-07-28 revision and the 2025 fallback; the stdio server stays on the v1 SDK, so add tools in `packages/mcp/src/tools.ts` once for both. Tools act through the SDK over loopback (`MCP_INTERNAL_API_URL`, default `http://127.0.0.1:$PORT`) with the caller'+ - **The TypeScript SDK is the reference for every SDK.** `packages/sdk/src/__tests__/conformance.test.ts` writes JSON vectors (signing, verification, forwarding signatures, matchers, durations) to `packages/sdk/conformance/`, and SDKs in other languages run them in their own CI, so their behaviour cannot drift; the Python and Go SDKs (`packages/sdk-python`, `packages/sdk-go`) read them straight from that directory. Go takes `time.Duration` rather than duration strings, so it skips `durations.jso+ - **Design system.** The UI ships in two styles: clean, the default (paper and ink: a cool paper canvas, white surfaces with hairlines instead of shadows, one pine green for buttons, links and selection, IBM Plex Sans with IBM Plex Mono), and classic (neobrutalist, Space Grotesk), which a signed-in user can pick under Account > Appearance along with light or dark mode. Both share one component tree; every page gets its header from `components/nav/site-header.tsx`, anchored at the top, with page + Split model. AGPL-3.0: `apps/web`, `apps/receiver-rs`, `apps/mx-rs`, `supabase/`. MIT: `apps/cli-rs`, `packages/sdk`, `packages/sdk-python`, `packages/sdk-go`, `packages/mcp`, `packages/zapier-app`, `packages/make-app`, `plugins/`.