From 7e5d7b4c0772240e22b8aa4c612e7213a28f6a05 Mon Sep 17 00:00:00 2001 From: TJ Baker <1617679+zaridan@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:06:37 -0700 Subject: [PATCH 1/2] docs(modules): operator guide for the module substrate (HT-71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds docs/modules/{README,webhooks,assistants-and-drafts}.md, written and verified against the shipped code rather than the spec alone: curl examples cross-checked against src/api/router.ts's actual patterns and validation (src/api/webhooks.ts, src/store/webhook-endpoints.ts), and the signature sample verified byte-for-byte against both the engine's signer (src/webhooks/delivery.ts) and the reference module's verifier (module-draft-assistant/src/verify.ts) via a throwaway script (exit 0). Assistants/drafts sections document PR #80 (HT-70), not yet on main — flagged at the top of assistants-and-drafts.md; merge this after #80. Co-Authored-By: Claude Sonnet 5 --- docs/modules/README.md | 90 ++++++++ docs/modules/assistants-and-drafts.md | 234 ++++++++++++++++++++ docs/modules/webhooks.md | 302 ++++++++++++++++++++++++++ 3 files changed, 626 insertions(+) create mode 100644 docs/modules/README.md create mode 100644 docs/modules/assistants-and-drafts.md create mode 100644 docs/modules/webhooks.md diff --git a/docs/modules/README.md b/docs/modules/README.md new file mode 100644 index 0000000..a6f1e0c --- /dev/null +++ b/docs/modules/README.md @@ -0,0 +1,90 @@ +# Helpthread module substrate + +This is the operator- and module-author-facing guide to Helpthread's **module +substrate**: the HTTP surface that lets an out-of-process extension — a +draft-writing AI, a CRM sync, a notification bot, anything — connect to a +Helpthread deployment without any code living inside the core repo. + +It documents the substrate as it is **shipped**, not as it was specified. +Where the two disagree, this guide follows the code and says so. + +## Vocabulary (fixed — used the same way everywhere: schema, code, UI, docs) + +- **Module** — an out-of-process Helpthread extension. Never called a + "plugin" (that word survives only inside the legal phrase *plugin + exception*, the AGPL §7 additional permission — it is not a synonym for + "module" anywhere in this substrate). +- **Agent** — a human support-staff user. Agents log in, see the inbox UI, + and approve or discard AI-drafted replies. +- **Assistant** — an AI actor principal. Assistants authenticate with their + own bearer token, read conversations, and post draft replies — they can + never send mail directly. + +Do not conflate Agents and Assistants; the schema, the API, and the auth +model treat them as two entirely different kinds of caller with different +credentials and different capabilities. + +## The three surfaces + +| Surface | What it does | Guide | +|---|---|---| +| **Typed events** | The engine records eight kinds of domain event (a new conversation, inbound mail, a status change, a resolved draft, …) reliably, in the same transaction as the change they describe. | [webhooks.md](./webhooks.md) | +| **Webhook delivery** | Registered HTTPS endpoints receive signed, at-least-once notifications of those events. | [webhooks.md](./webhooks.md) | +| **Assistant actors** | AI principals that authenticate with a bearer token, read conversations through the same read API Agents use, and post draft replies that a human Agent must approve before anything is sent. | [assistants-and-drafts.md](./assistants-and-drafts.md) | + +A module typically uses all three: it hears about inbound mail via a +webhook, reads the full conversation via the API, and posts a draft back as +an Assistant. That is exactly the shape of the first real module, +`module-draft-assistant`, referenced throughout these docs as a worked +example. + +## Where the substrate lives on the wire + +Every route below sits under `/api/v1` on your Helpthread deployment's base +URL (e.g. `https://your-helpdesk.example.com`). There is no separate "module +API" host — it is the same Agent Inbox API a human Agent's browser talks to, +with two additional credential classes layered on top of the original +service-token model. + +### Who calls what, authenticated how + +| Caller | Credential | Used for | +|---|---|---| +| **Operator / admin tooling** | `Authorization: Bearer ` (the deployment's one service token) **plus** `X-Helpthread-Agent-Id: ` | Registering webhooks, creating/rotating Assistants, approving or discarding drafts — anything an admin Agent does from a script instead of the UI. | +| **A module, at runtime** | `Authorization: Bearer ht_asst__` (the Assistant's own token) | Reading conversations, posting drafts, posting notes — nothing else (see [assistants-and-drafts.md](./assistants-and-drafts.md)'s fixed capability set). | +| **A module's webhook receiver** | No inbound credential — instead it *verifies* the `X-Helpthread-Signature` header on every delivery it receives (see [webhooks.md](./webhooks.md)). | Confirming a delivery genuinely came from your Helpthread deployment. | + +Every non-2xx response from this API, on every route, uses the same JSON +error envelope: + +```json +{ "error": { "code": "validation_failed", "message": "..." } } +``` + +and every response — success or error — is sent with `Cache-Control: +no-store` (this is authenticated support data; it is never safe to cache). + +## Non-goals for v1 (deliberately not built yet) + +Carried over honestly from the spec, because a module author should not go +looking for these: + +- No in-process/build-time module API — modules are out-of-process only. +- No UI injection points. +- No general scopes/permissions system — an Assistant's capability set is a + small fixed list (see [assistants-and-drafts.md](./assistants-and-drafts.md)), + not something you configure. +- No marketplace plumbing — license keys, a module registry, usage metering. +- No webhook redelivery tooling beyond the one-off `POST .../test` ping. + +Each of these waits for a real module that needs it. + +## Guides + +- **[webhooks.md](./webhooks.md)** — registering an endpoint, the event + vocabulary and envelope, verifying `X-Helpthread-Signature` (complete, + runnable TypeScript sample), delivery guarantees, auto-disable and health + visibility. +- **[assistants-and-drafts.md](./assistants-and-drafts.md)** — creating an + Assistant and handling its token, the fixed capability set, posting a + draft, and the human Agent approval flow. diff --git a/docs/modules/assistants-and-drafts.md b/docs/modules/assistants-and-drafts.md new file mode 100644 index 0000000..0fd1c72 --- /dev/null +++ b/docs/modules/assistants-and-drafts.md @@ -0,0 +1,234 @@ +# Assistants: identity, capabilities, drafts, and approval + +> **Status note.** This document covers the Assistants and Drafts APIs +> (HT-70), which land on `main` via PR #80. The code referenced here lives on +> branch `feat/ht-70-drafts-approval` until that PR merges; everything below +> was verified against that branch's shipped code, not the spec alone. + +An **Assistant** is an AI actor principal — never a human (that's an +**Agent**; see [README.md](./README.md)'s vocabulary section). A module that +wants to read conversations and propose replies authenticates as an +Assistant, using a bearer token an admin Agent mints for it. An Assistant +can never send mail directly: everything it writes is a draft, and a human +Agent must approve it before anything reaches the customer. + +Examples below use the same `$BASE_URL`, `$HELPTHREAD_API_TOKEN`, and +`$ADMIN_AGENT_ID` as [webhooks.md](./webhooks.md) for the **admin** +endpoints (creating/managing Assistants, approving/discarding drafts — all +Agent actions). The Assistant's own token, once minted, is a completely +separate credential used only by the module itself. + +## Creating an assistant + +```sh +curl -X POST "$BASE_URL/api/v1/assistants" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" \ + -H "Content-Type: application/json" \ + -d '{"name": "Draft Assistant", "module": "your-module-slug"}' +``` + +Both `name` (1–200 characters) and `module` (1–100 characters, free text — +nothing validates it against a registry) are required; either missing or +out of range is `400 validation_failed`. + +Response, `201`: + +```json +{ + "assistant": { + "id": "c7a1...-uuid", + "name": "Draft Assistant", + "module": "your-module-slug", + "status": "active", + "createdByAgentId": "", + "createdAt": "2026-07-19T00:00:00.000Z", + "updatedAt": "2026-07-19T00:00:00.000Z" + }, + "token": "ht_asst_c7a1...-uuid_" +} +``` + +## Token handling + +**`token` is shown exactly once, in the create (or rotate) response.** Only +a SHA-256 digest of its secret half is ever persisted — there is no "reveal +token" endpoint and no way to recover a lost one. Copy it immediately into +wherever your module reads its configuration (an environment variable is +the normal choice) and treat it like any other high-entropy secret: never +commit it, never log it. + +The token has the shape `ht_asst__`. Use it as-is on +every Assistant-authenticated request: + +```sh +curl "$BASE_URL/api/v1/conversations/$CONVERSATION_ID" \ + -H "Authorization: Bearer $ASSISTANT_TOKEN" +``` + +Note there is **no** `X-Helpthread-Agent-Id` header on Assistant- +authenticated calls — the token itself carries the Assistant's identity; +that header is only for Agent-authenticated calls (creating/managing +Assistants, approving/discarding drafts — see below). + +**Rotation** mints a fresh secret for the *same* assistant id (so any +`author_assistant_id` a past draft already recorded stays valid) and +returns the new token once; the old one stops verifying immediately — +there is no overlap window: + +```sh +curl -X POST "$BASE_URL/api/v1/assistants/$ASSISTANT_ID/rotate-token" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" +``` + +Also available, both admin-only: `GET /api/v1/assistants` (roster, never +includes any token) and `PATCH /api/v1/assistants/{id}` with `{"name": +...}` and/or `{"status": "active" | "disabled"}` — a `disabled` Assistant's +token stops authenticating immediately, without needing rotation. + +## The fixed capability set + +An Assistant's token authenticates it, but that alone doesn't authorize +every route — there is exactly one capability-enforcement point +(`src/api/index.ts`), and it allows an Assistant through to only: + +- `GET /api/v1/conversations` and `GET /api/v1/conversations/{id}` — the + same read surface an Agent's UI uses, so a module can pull full thread + content once a webhook tells it something changed. +- `POST /api/v1/conversations/{id}/drafts` — propose a reply (below). +- `POST /api/v1/conversations/{id}/notes` — leave an internal note. + +Every other route — including anything under `/api/v1/webhooks`, +`/api/v1/assistants`, sending a reply, changing status/tags/assignee, or +approving/discarding a draft — answers `403 forbidden` to an Assistant +caller, even though the token itself is valid. There is no scopes system to +configure this differently; a wider capability set waits for a real module +that needs one. + +Soft-deleted conversations are invisible to an Assistant exactly as they +are to everyone else: a `404`, indistinguishable from never having existed. + +## Posting a draft + +```sh +curl -X POST "$BASE_URL/api/v1/conversations/$CONVERSATION_ID/drafts" \ + -H "Authorization: Bearer $ASSISTANT_TOKEN" \ + -H "Idempotency-Key: $EVENT_ID" \ + -H "Content-Type: application/json" \ + -d '{"bodyText": "Thanks for reaching out — here is how to reset your password..."}' +``` + +- `bodyText` is required, 1–5000 characters. `bodyHtml` is optional (no + length bound, but must be a string if present). +- `Idempotency-Key` is **required** — use the triggering webhook delivery's + `eventId` (the pattern this guide recommends throughout): if the same + `conversation.message_received` delivery is retried, replaying the same + `Idempotency-Key` against the same conversation returns the original + draft instead of creating a second one. This is enforced server-side, not + just a convention — draft creation is idempotent by construction. + +Response, `201`, a `ThreadView` (the same shape the conversation-detail +endpoint returns for any thread): + +```json +{ + "id": "d4e2...-uuid", + "direction": "outbound", + "from": "support@your-helpdesk.example.com", + "bodyText": "Thanks for reaching out — here is how to reset your password...", + "bodyHtml": null, + "deliveryStatus": null, + "customerViewedAt": null, + "attachments": [], + "createdAt": "2026-07-19T00:00:05.000Z", + "authorKind": "assistant", + "draftStatus": "awaiting_review" +} +``` + +Note `deliveryStatus: null` — a draft is inert until an Agent approves it; +nothing about posting a draft can cause mail to leave the system. Posting a +draft fires a `draft.created` event ([webhooks.md](./webhooks.md)'s +vocabulary) with `{ threadId, assistantId }`. An unresolved draft also does +**not** reopen a closed conversation or bump its activity timestamp — a +draft sitting in the review queue is not, by itself, evidence that a human +looked at anything. + +## The Agent approval flow + +Everything past this point is an **Agent** action — the core Helpthread +inbox UI does this for a human clicking "approve" or "discard," and it +consumes exactly the same API, so these are also the calls a module author +would use to build their own review tooling or to understand what the UI is +doing. All three require `Authorization: Bearer $HELPTHREAD_API_TOKEN` + +`X-Helpthread-Agent-Id: ` — missing either is `401`. + +**List the review queue** (every conversation's drafts, across the whole +deployment, newest first): + +```sh +curl "$BASE_URL/api/v1/drafts?status=awaiting_review" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" +``` + +`status=awaiting_review` is required and is the only legal value — resolved +drafts show up in their conversation's own detail view, not here. Supports +`limit` (default 25, max 50) and keyset `cursor` pagination via the +returned `nextCursor`. + +**Approve**, optionally editing the body first: + +```sh +# Approve as-written +curl -X POST "$BASE_URL/api/v1/drafts/$THREAD_ID/approve" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" + +# Approve with edits (recorded as draftEdited: true) +curl -X POST "$BASE_URL/api/v1/drafts/$THREAD_ID/approve" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" \ + -H "Content-Type: application/json" \ + -d '{"bodyText": "Edited reply text..."}' +``` + +Approval is a state transition, not a resend: it mints the reply's +threading token and Message-ID, derives the envelope (recipient, subject, +`In-Reply-To`/`References`) exactly the way a normal Agent reply does, and +hands off to the same delivery worker — the mail that goes out is +equivalent to what a human typing the same body and hitting reply would +send. Fires `draft.resolved` immediately (`{ threadId, resolution: +'approved', edited }`), and `conversation.reply_sent` once delivery actually +confirms `sent` (not at accept-for-send time — modules reacting to "we +replied" get truth, not intent). + +Refused `404` (indistinguishable-from-nonexistent) if the conversation is +missing/soft-deleted or `$THREAD_ID` doesn't name a draft currently +`awaiting_review`; refused `409 conflict` if the conversation is `spam`. + +**Discard** (no send, row kept for audit): + +```sh +curl -X POST "$BASE_URL/api/v1/drafts/$THREAD_ID/discard" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" +``` + +Sets `draftStatus: 'discarded'` and fires `draft.resolved` (`{ threadId, +resolution: 'discarded', edited: false }`). No `spam` restriction — discarding +a draft on a spam conversation is harmless, unlike approving one. + +Both approve and discard return the updated `ThreadView` on success, the +same shape draft-creation returns above. + +## Invariants worth knowing, test-asserted in the engine + +- An Assistant call can never, by itself, cause outbound mail — the only + path to a sent message is an Agent's explicit approval. +- A draft never leaves the system without an approving Agent's identity + recorded on the row (`approved_by_agent_id`). +- An unresolved or discarded draft is excluded from a conversation's + preview text and thread count — it isn't conversation content until it + sends. diff --git a/docs/modules/webhooks.md b/docs/modules/webhooks.md new file mode 100644 index 0000000..f962384 --- /dev/null +++ b/docs/modules/webhooks.md @@ -0,0 +1,302 @@ +# Webhooks: events, delivery, and signature verification + +This covers the admin API for registering a webhook endpoint, the event +vocabulary and envelope every delivery carries, how to verify a delivery's +signature, the delivery guarantees you can rely on, and what happens when +your endpoint starts failing. + +All examples assume `$BASE_URL` is your deployment's origin (e.g. +`https://your-helpdesk.example.com`), `$HELPTHREAD_API_TOKEN` is the +deployment's service Bearer token, and `$ADMIN_AGENT_ID` is the uuid of an +admin-role Agent — every admin route below requires **both** headers. There +is no separate "webhooks API key"; this is the same admin surface an admin +Agent's own browser session uses. + +## Registering a webhook + +```sh +curl -X POST "$BASE_URL/api/v1/webhooks" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" \ + -H "Content-Type: application/json" \ + -d '{ + "url": "https://your-module.example.com/webhooks/helpthread", + "events": ["conversation.message_received"], + "module": "your-module-slug" + }' +``` + +- `url` — required, `https://` only (no other scheme is accepted, and + registering `http://` is rejected at this call, not silently downgraded), + at most 2048 characters. +- `events` — optional array drawn from the vocabulary below. Omit it (or + send `[]`) to subscribe to **every** event type. +- `module` — optional free-text slug identifying which module owns this + endpoint. Purely attribution; nothing validates it against a registry + today. + +Response, `201`: + +```json +{ + "webhook": { + "id": "b3f6...-uuid", + "url": "https://your-module.example.com/webhooks/helpthread", + "events": ["conversation.message_received"], + "module": "your-module-slug", + "status": "active", + "consecutiveFailures": 0, + "createdAt": "2026-07-19T00:00:00.000Z", + "updatedAt": "2026-07-19T00:00:00.000Z", + "secret": "base64url-256-bit-secret..." + } +} +``` + +**`secret` is returned exactly once, in this response.** It is encrypted at +rest server-side and never appears in any later `GET`/`PATCH` response — +there is no "reveal secret" endpoint. If you lose it, delete the endpoint +and register a new one (v1 has no secret-rotation route for webhooks, unlike +Assistant tokens — see [assistants-and-drafts.md](./assistants-and-drafts.md)). + +### Listing, updating, deleting + +```sh +# List every registered endpoint (never includes the secret) +curl "$BASE_URL/api/v1/webhooks" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" + +# Update url/events/module/status (any subset; unknown fields are a 400) +curl -X PATCH "$BASE_URL/api/v1/webhooks/$WEBHOOK_ID" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" \ + -H "Content-Type: application/json" \ + -d '{"events": ["conversation.message_received", "draft.resolved"]}' + +# Hard delete +curl -X DELETE "$BASE_URL/api/v1/webhooks/$WEBHOOK_ID" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" +``` + +`status` may only be set to `"active"` or `"disabled"` via `PATCH` — +`"auto_disabled"` is written by the engine only (see +[Auto-disable](#auto-disable-and-health-visibility) below); to clear it, +`PATCH` to `"active"` explicitly, which also resets the failure counter. + +### Testing an endpoint + +```sh +curl -X POST "$BASE_URL/api/v1/webhooks/$WEBHOOK_ID/test" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" +``` + +Fires a synthetic `test.ping` event through the **real** delivery path +(same signing, same SSRF checks, same timeout) directly at this one +endpoint, ignoring its `events` filter. `202 { "status": "queued" }` on +success. `test.ping` is never a value you can put in an endpoint's `events` +array — it only ever exists as this one synthetic delivery, and its +envelope's `conversationId` is `null` and `data` is `{}`. + +Only an `active` endpoint can be tested — a `disabled`/`auto_disabled` +endpoint gets `409 conflict`; re-enable it first. + +## Event vocabulary and envelope + +Eight real domain event types, closed list — nothing else is ever +delivered as a non-test event: + +| Type | Fired when | `data` | +|---|---|---| +| `conversation.created` | A new conversation is stored | — | +| `conversation.message_received` | Inbound mail is stored on a conversation (including a reopen of a closed one) | `threadId`, `reopened` | +| `conversation.reply_sent` | An outbound reply's delivery is confirmed `sent` (not merely accepted) | `threadId`, `authorKind` | +| `conversation.status_changed` | Conversation status transitions among `active`/`pending`/`closed`/`spam` | `from`, `to` | +| `conversation.tags_changed` | A conversation's tag set is replaced | `tags` | +| `conversation.assignee_changed` | A conversation's assignee is set or cleared | `assigneeAgentId` | +| `draft.created` | An Assistant posts a draft | `threadId`, `assistantId` | +| `draft.resolved` | An Agent approves or discards a draft | `threadId`, `resolution`, `edited` | + +**Events are thin by design.** `data` carries only identifiers and small +typed facts — never a message body, subject line, or address. Fetch full +content through the read API (`GET /api/v1/conversations/{id}`) with your +own credentials once an event tells you something changed. This keeps every +webhook payload free of customer content and PII by construction. + +**Soft-deleted conversations fire nothing.** Deletion is invisible on every +other endpoint (a `404`, indistinguishable from never having existed) and +the same holds here: no event of any type fires for a soft-deleted +conversation, including a `draft.*` for a draft stranded on it. + +Every delivery's JSON body is exactly this envelope: + +```json +{ + "eventId": "uuid", + "type": "conversation.message_received", + "occurredAt": "2026-07-19T12:00:00.000Z", + "conversationId": "uuid", + "data": { "threadId": "uuid", "reopened": false } +} +``` + +`conversationId` is `null` only for the synthetic `test.ping` — every real +event always carries one. + +## Headers on every delivery + +| Header | Value | +|---|---| +| `X-Helpthread-Event` | The event `type` (redundant with the body, provided for routing without a JSON parse) | +| `X-Helpthread-Delivery` | A fresh uuid on **every** HTTP attempt, including a retry of the same event — do not use this for dedupe | +| `X-Helpthread-Signature` | `t=, v1=` — see below | +| `Content-Type` | `application/json` | + +**Dedupe on `eventId`, in the body — never on `X-Helpthread-Delivery`.** +`eventId` is the one value stable across every redelivery of the same +event; the delivery header changes on every attempt by design. + +## Verifying the signature + +`X-Helpthread-Signature` is `t=, v1=`, where the hex +value is `HMAC-SHA256(secret, ".")`, keyed by the +endpoint's own signing secret (the one shown once at registration). This is +the Stripe-shape scheme; the engine's signer lives in +`src/webhooks/delivery.ts`'s `signWebhookPayload`. + +Verify against the **raw** request body bytes, before any `JSON.parse` — +the signature covers exactly what was sent, and re-serializing a parsed +object is not guaranteed to reproduce the same bytes. + +Complete, runnable TypeScript sample: + +```typescript +// verify-signature.ts +import { createHmac, timingSafeEqual } from 'node:crypto' + +export type VerifyResult = { valid: true } | { valid: false; reason: string } + +const DEFAULT_TOLERANCE_SECONDS = 5 * 60 // recommended replay window + +export function verifyWebhookSignature( + header: string | null, + rawBody: string, + secret: string, + toleranceSeconds: number = DEFAULT_TOLERANCE_SECONDS, +): VerifyResult { + if (!header) { + return { valid: false, reason: 'missing signature header' } + } + + const fields = Object.fromEntries( + header.split(',').map((part) => { + const [key, value] = part.trim().split('=') + return [key, value] + }), + ) + const timestamp = Number(fields.t) + const signature = fields.v1 + if (!Number.isFinite(timestamp) || !signature) { + return { valid: false, reason: 'malformed signature header' } + } + + const now = Math.floor(Date.now() / 1000) + if (Math.abs(now - timestamp) > toleranceSeconds) { + return { valid: false, reason: 'stale timestamp — possible replay' } + } + + const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex') + const expectedBuf = Buffer.from(expected, 'hex') + const providedBuf = Buffer.from(signature, 'hex') + if (expectedBuf.length !== providedBuf.length || !timingSafeEqual(expectedBuf, providedBuf)) { + return { valid: false, reason: 'signature mismatch' } + } + + return { valid: true } +} +``` + +Usage in a receiving handler (framework-agnostic — read the raw body +**before** any JSON body-parser middleware consumes it): + +```typescript +const result = verifyWebhookSignature( + request.headers.get('x-helpthread-signature'), + rawBody, // the exact bytes received, not JSON.parse(rawBody) re-stringified + process.env.WEBHOOK_SIGNING_SECRET!, +) +if (!result.valid) { + return new Response('invalid signature', { status: 401 }) +} +const event = JSON.parse(rawBody) +``` + +Reject a stale `t` (the 5-minute default above matches the spec's +recommendation) to close a replay window — an attacker who captures one +valid delivery cannot resend it indefinitely. + +> **Verified, not just written.** This exact function was checked two ways +> before landing in this doc: (1) signed with the engine's own +> `signWebhookPayload` (`src/webhooks/delivery.ts`) and verified successfully +> by this function, byte-for-byte, with a real HMAC computed both ways; (2) +> cross-checked against the independent verifier in +> `module-draft-assistant/src/verify.ts` (the reference module referenced +> throughout this guide) — both verifiers agree on the same signed payload, +> and both correctly reject a wrong secret, a tampered body, and a stale +> timestamp. The throwaway script that ran this check exited `0`. + +## Delivery guarantees + +| Guarantee | What it means for you | +|---|---| +| **At-least-once** | The same event may arrive more than once. Always dedupe on `eventId`, never assume exactly-once. | +| **No cross-event ordering** | Two events for the same conversation can arrive out of order (different retries, different queue timing). Don't infer sequence from delivery order — the envelope's `occurredAt` and your own read of current state via the API are the source of truth. | +| **Thin payloads** | `data` never carries message content — fetch it via the read API with your own credentials. | +| **2xx acks, anything else retries** | Any `2xx` status is success. A non-2xx, a timeout (10s hard deadline), or a connection error is a failed attempt and goes through the queue's retry/backoff. Redirects are never followed — a `3xx` is a failure, not a hop. | +| **HTTPS only, SSRF-checked at delivery time** | Only `https://` endpoints are ever registered, and every delivery attempt resolves the endpoint's hostname and refuses to connect if it resolves to a private/loopback/link-local address — even if the hostname resolved to a public address when you registered it. If your endpoint's DNS changes to something disallowed, deliveries start failing, not the registration. | + +## Auto-disable and health visibility + +After **20 consecutive** failed delivery attempts to one endpoint, it flips +from `active` to `auto_disabled` automatically — this is conservative on +purpose: a silently-disabled endpoint means a paid module's user stops +getting the events it depends on without anyone noticing, so the threshold +errs toward catching that quickly. A single success resets the counter to +zero without touching status; only a deliberate `PATCH .../{id} +{"status":"active"}` re-enables an `auto_disabled` endpoint (which also +resets the counter). + +Two operator-visible signals surface this, both via `GET +/api/v1/webhooks` (`status` and `consecutiveFailures` on each row) and via +the deployment's internal health endpoint: + +```sh +curl "$BASE_URL/api/v1/internal/health" \ + -H "Authorization: Bearer $CRON_SECRET" +``` + +which reports, among other sections: + +```json +{ + "ok": false, + "alerts": ["webhook-endpoint-auto-disabled: 1 webhook endpoint(s) auto-disabled ..."], + "webhooks": { + "autoDisabled": [ + { "id": "...", "url": "https://your-module.example.com/webhooks/helpthread", "consecutiveFailures": 20 } + ], + "deliveryFailuresLast24h": 3 + } +} +``` + +`/api/v1/internal/health` answers `200` when healthy and `503` when any +alert (including this one) is tripped — a plain status-code monitor is a +complete alerting story; you don't need to parse the body to know something +needs attention. This endpoint is an **operator** concern (it needs the +deployment's `CRON_SECRET`, a separate credential from anything a module +holds) — documented here so a module author building against a self-hosted +Helpthread instance knows where their own endpoint's health is visible to +the person running it. From f7955714bf08774f3c5dc515eb403f035397470b Mon Sep 17 00:00:00 2001 From: TJ Baker <1617679+zaridan@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:32:46 -0700 Subject: [PATCH 2/2] docs(modules): address CodeRabbit review on PR #81 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - assistants-and-drafts.md: drop the pre-merge branch/PR-#80 status note now that #80 is merged and deployed; content documents merged main. - assistants-and-drafts.md: stop claiming every Assistant write is a draft — notes (POST .../notes) are direct writes with no draft/ approval step, only customer-facing replies are draft-gated. Verified against handlePostNote (src/api/conversations.ts on merged main). - README.md: "draft-writing Assistant", not "draft-writing AI" — vocabulary rule (Assistants, never generic "AI"). - webhooks.md: the signature-verification sample now requires an exact 64-hex-char match before Buffer.from(..., 'hex') decoding — that call otherwise silently truncates at the first non-hex character instead of rejecting trailing garbage after a valid-length prefix. Re-verified with an extended throwaway script (adds the trailing-garbage negative case to the existing engine-signer/reference-module cross-check); exit 0. - webhooks.md: split the delivery-guarantees table's "anything else retries" row — an SsrfRefusedError dead-letters immediately on the first attempt and is never retried, unlike an ordinary HTTP failure/ timeout/connection error. Verified against src/webhooks/delivery.ts's catch branch on merged main. Rebased onto origin/main (PR #80 merged as 475b60e) so both the docs and the verification below reflect merged main, not the pre-merge branch. Co-Authored-By: Claude Sonnet 5 --- docs/modules/README.md | 4 ++-- docs/modules/assistants-and-drafts.md | 13 +++++------ docs/modules/webhooks.md | 33 +++++++++++++++++++-------- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/docs/modules/README.md b/docs/modules/README.md index a6f1e0c..3cf7740 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -2,8 +2,8 @@ This is the operator- and module-author-facing guide to Helpthread's **module substrate**: the HTTP surface that lets an out-of-process extension — a -draft-writing AI, a CRM sync, a notification bot, anything — connect to a -Helpthread deployment without any code living inside the core repo. +draft-writing Assistant, a CRM sync, a notification bot, anything — connect +to a Helpthread deployment without any code living inside the core repo. It documents the substrate as it is **shipped**, not as it was specified. Where the two disagree, this guide follows the code and says so. diff --git a/docs/modules/assistants-and-drafts.md b/docs/modules/assistants-and-drafts.md index 0fd1c72..c48e7db 100644 --- a/docs/modules/assistants-and-drafts.md +++ b/docs/modules/assistants-and-drafts.md @@ -1,16 +1,15 @@ # Assistants: identity, capabilities, drafts, and approval -> **Status note.** This document covers the Assistants and Drafts APIs -> (HT-70), which land on `main` via PR #80. The code referenced here lives on -> branch `feat/ht-70-drafts-approval` until that PR merges; everything below -> was verified against that branch's shipped code, not the spec alone. - An **Assistant** is an AI actor principal — never a human (that's an **Agent**; see [README.md](./README.md)'s vocabulary section). A module that wants to read conversations and propose replies authenticates as an Assistant, using a bearer token an admin Agent mints for it. An Assistant -can never send mail directly: everything it writes is a draft, and a human -Agent must approve it before anything reaches the customer. +can never send mail directly: every customer-facing reply it writes is a +draft, and a human Agent must approve it before anything reaches the +customer. An internal note is the one exception — `POST +/api/v1/conversations/{id}/notes` (below) is a direct write, visible to +Agents immediately, with no draft/approval step, because a note never +reaches the customer in the first place. Examples below use the same `$BASE_URL`, `$HELPTHREAD_API_TOKEN`, and `$ADMIN_AGENT_ID` as [webhooks.md](./webhooks.md) for the **admin** diff --git a/docs/modules/webhooks.md b/docs/modules/webhooks.md index f962384..6391791 100644 --- a/docs/modules/webhooks.md +++ b/docs/modules/webhooks.md @@ -207,6 +207,15 @@ export function verifyWebhookSignature( return { valid: false, reason: 'stale timestamp — possible replay' } } + // Require an exact 64-character hex digest (a SHA-256 HMAC) before + // decoding — Buffer.from(str, 'hex') silently stops at the first + // non-hex character rather than rejecting the string, so a signature + // with trailing garbage after a valid prefix would otherwise decode + // instead of being caught here as malformed. + if (!/^[0-9a-fA-F]{64}$/.test(signature)) { + return { valid: false, reason: 'malformed signature header' } + } + const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex') const expectedBuf = Buffer.from(expected, 'hex') const providedBuf = Buffer.from(signature, 'hex') @@ -237,15 +246,18 @@ Reject a stale `t` (the 5-minute default above matches the spec's recommendation) to close a replay window — an attacker who captures one valid delivery cannot resend it indefinitely. -> **Verified, not just written.** This exact function was checked two ways -> before landing in this doc: (1) signed with the engine's own -> `signWebhookPayload` (`src/webhooks/delivery.ts`) and verified successfully -> by this function, byte-for-byte, with a real HMAC computed both ways; (2) -> cross-checked against the independent verifier in -> `module-draft-assistant/src/verify.ts` (the reference module referenced -> throughout this guide) — both verifiers agree on the same signed payload, -> and both correctly reject a wrong secret, a tampered body, and a stale -> timestamp. The throwaway script that ran this check exited `0`. +> **Verified, not just written.** This exact function was checked before +> landing in this doc, and re-checked after the hex-validation fix above: +> (1) signed with the engine's own `signWebhookPayload` +> (`src/webhooks/delivery.ts`) and verified successfully by this function, +> byte-for-byte, with a real HMAC computed both ways; (2) cross-checked +> against the independent verifier in `module-draft-assistant/src/verify.ts` +> (the reference module referenced throughout this guide) — both verifiers +> agree on the same signed payload; (3) correctly rejects a wrong secret, a +> tampered body, a stale timestamp, and a signature with trailing non-hex +> garbage appended after a valid-length prefix (`Buffer.from(str, 'hex')` +> otherwise silently truncates instead of rejecting it). The throwaway +> script that ran these checks exited `0`. ## Delivery guarantees @@ -254,8 +266,9 @@ valid delivery cannot resend it indefinitely. | **At-least-once** | The same event may arrive more than once. Always dedupe on `eventId`, never assume exactly-once. | | **No cross-event ordering** | Two events for the same conversation can arrive out of order (different retries, different queue timing). Don't infer sequence from delivery order — the envelope's `occurredAt` and your own read of current state via the API are the source of truth. | | **Thin payloads** | `data` never carries message content — fetch it via the read API with your own credentials. | -| **2xx acks, anything else retries** | Any `2xx` status is success. A non-2xx, a timeout (10s hard deadline), or a connection error is a failed attempt and goes through the queue's retry/backoff. Redirects are never followed — a `3xx` is a failure, not a hop. | +| **2xx acks, anything else retries** | Any `2xx` status is success. A non-2xx HTTP response, a timeout (10s hard deadline), or a connection error is a failed attempt and goes through the queue's retry/backoff. Redirects are never followed — a `3xx` is a failure, not a hop. | | **HTTPS only, SSRF-checked at delivery time** | Only `https://` endpoints are ever registered, and every delivery attempt resolves the endpoint's hostname and refuses to connect if it resolves to a private/loopback/link-local address — even if the hostname resolved to a public address when you registered it. If your endpoint's DNS changes to something disallowed, deliveries start failing, not the registration. | +| **SSRF refusals are NOT retried** | Unlike an ordinary HTTP failure, an SSRF refusal is dead-lettered immediately on the first attempt, never queued for retry — retrying can't change what a hostname resolves to, so burning the retry budget on it would only delay the signal that your endpoint needs attention. This counts toward the endpoint's [auto-disable](#auto-disable-and-health-visibility) failure counter the same as any other dead-lettered delivery. | ## Auto-disable and health visibility