feat(integration): incoming webhooks (post-as URL) (LC-74) #170

Merged
nrupard merged 2 commits from feat/lc-74-incoming-webhooks into main 2026-05-21 15:34:18 +02:00
Owner

Summary

Incoming webhooks (LC-74). A room moderator creates a secret URL; any external system POSTs JSON to it and the message appears in the room attributed to a synthetic webhook actor (not a real user). Builds on the merged LC-72 secret-hashing pattern.

Design

  • Synthetic actor: messages.webhook_id (nullable, migration 0041). Webhook rows store user_id = '' + webhook_id - messages.user_id has no cross-db FK, so an empty id is safe and avoids making the column Option across the whole codebase. RawMessage/Message/MessageView carry webhook_id; a new resolve_msg_author branches on it (returns the webhook's name/avatar via AuthorMeta { is_webhook, avatar_url }). All 9 MessageView build sites + the WS broadcast path go through it. Template renders a webhook badge, drops the DM link, uses the avatar URL (or initials).
  • Storage (incoming_webhooks, chat.db): room_id FK ON DELETE CASCADE, name, optional avatar URL, only an HMAC of the secret (keyed by LETS_CHAT_SECRET_KEY). revoked_at retains the row for audit.
  • Public route POST /webhook/{secret}: unauthenticated, merged after TraceLayer so the secret never hits request logs. Body {"text": "...", "markdown": bool}; markdown:true renders through the LC-59 pipeline, else escaped to literal. 401 unknown, 410 revoked, 429 + Retry-After past the per-webhook cap (60/min), 204 success. Broadcasts + fans out @mentions.
  • Management UI (cookie-authed, room-moderator gated): GET/POST /room/{id}/webhooks (create reveals URL once), POST .../{wid}/revoke, linked from the room Moderators page. docs/api.md documents it.

Acceptance criteria

  • Admin creates a webhook and copies the URL once.
  • POST appends a message attributed to the webhook, not a user.
  • Display name + avatar configurable.
  • Revoke -> subsequent POSTs 410.
  • Rate limit -> 429 + Retry-After.
  • Webhook URLs not logged (only the id; route merged past TraceLayer).
  • Markdown applies when "markdown": true.
  • Deleting the room cascades and removes the webhook rows.

Tests

routes_webhooks.rs: create+attribute (badge render), unknown-secret 401, revoke 410, rate-limit 429 + Retry-After, markdown flag, room-delete cascade. Migration 0041 appended to all hand-rolled chat lists. just check, just test, just test-saas green.

🤖 Generated with Claude Code

## Summary Incoming webhooks (LC-74). A room moderator creates a secret URL; any external system POSTs JSON to it and the message appears in the room attributed to a synthetic webhook actor (not a real user). Builds on the merged LC-72 secret-hashing pattern. ## Design - **Synthetic actor**: `messages.webhook_id` (nullable, migration `0041`). Webhook rows store `user_id = ''` + `webhook_id` - `messages.user_id` has no cross-db FK, so an empty id is safe and avoids making the column `Option` across the whole codebase. `RawMessage`/`Message`/`MessageView` carry `webhook_id`; a new `resolve_msg_author` branches on it (returns the webhook's name/avatar via `AuthorMeta { is_webhook, avatar_url }`). All 9 MessageView build sites + the WS broadcast path go through it. Template renders a `webhook` badge, drops the DM link, uses the avatar URL (or initials). - **Storage** (`incoming_webhooks`, chat.db): `room_id` FK `ON DELETE CASCADE`, name, optional avatar URL, only an **HMAC** of the secret (keyed by `LETS_CHAT_SECRET_KEY`). `revoked_at` retains the row for audit. - **Public route** `POST /webhook/{secret}`: unauthenticated, merged **after** TraceLayer so the secret never hits request logs. Body `{"text": "...", "markdown": bool}`; `markdown:true` renders through the LC-59 pipeline, else escaped to literal. **401** unknown, **410** revoked, **429** + `Retry-After` past the per-webhook cap (60/min), **204** success. Broadcasts + fans out `@mentions`. - **Management UI** (cookie-authed, room-moderator gated): `GET/POST /room/{id}/webhooks` (create reveals URL once), `POST .../{wid}/revoke`, linked from the room Moderators page. `docs/api.md` documents it. ## Acceptance criteria - [x] Admin creates a webhook and copies the URL once. - [x] POST appends a message attributed to the webhook, not a user. - [x] Display name + avatar configurable. - [x] Revoke -> subsequent POSTs 410. - [x] Rate limit -> 429 + `Retry-After`. - [x] Webhook URLs not logged (only the id; route merged past TraceLayer). - [x] Markdown applies when `"markdown": true`. - [x] Deleting the room cascades and removes the webhook rows. ## Tests `routes_webhooks.rs`: create+attribute (badge render), unknown-secret 401, revoke 410, rate-limit 429 + Retry-After, markdown flag, room-delete cascade. Migration `0041` appended to all hand-rolled chat lists. `just check`, `just test`, `just test-saas` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(integration): incoming webhooks (post-as URL) (LC-74)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m21s
0837c3980f
A room moderator creates an incoming webhook for a room; any external system POSTs JSON to its secret URL and it appears as a message attributed to a synthetic webhook actor, not a real user.

Synthetic actor: messages gain a nullable webhook_id (migration 0041); webhook messages store user_id='' + webhook_id (messages.user_id has no cross-db FK, so an empty id is safe and avoids making the column Option across the whole codebase). RawMessage/Message/MessageView carry webhook_id; the 9 MessageView build sites resolve author identity through a new resolve_msg_author that branches on webhook_id, returning the webhook's name/avatar (AuthorMeta gains is_webhook + avatar_url). The message template renders a "webhook" badge, suppresses the DM link, and uses the webhook avatar URL (or initials).

Storage (incoming_webhooks, chat.db): room_id FK ON DELETE CASCADE (deleting a room removes its webhooks), name, optional avatar_url, and only an HMAC of the secret (keyed by LETS_CHAT_SECRET_KEY, like API tokens) - a chat.db leak cannot reconstruct usable URLs. revoked_at retains the row for audit.

Public route POST /webhook/{secret}: unauthenticated (the secret is the credential), merged AFTER the TraceLayer so the secret never lands in request logs (only the webhook id is logged from the handler). Body {"text": "...", "markdown": bool}; markdown:true renders through the LC-59 pipeline, otherwise the text is escaped to render literally. 401 unknown secret, 410 revoked, 429 + Retry-After past the per-webhook cap (60/min via a new RateLimitKind::Webhook), 204 on success. Webhook messages broadcast through the room hub and fan out @mentions (no author to exclude).

Management UI (cookie-authed, room-moderator gated): GET/POST /room/{id}/webhooks (create reveals the URL once) and POST /room/{id}/webhooks/{wid}/revoke, linked from the room Moderators page. docs/api.md documents the endpoint, payload, and status codes.

Tests: routes_webhooks.rs covers create+attribute, unknown-secret 401, revoke 410, rate-limit 429 + Retry-After, the markdown flag, and room-delete cascade. Migration 0041 appended to every hand-rolled chat migration list. just check, just test, just test-saas all green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fix(webhooks): cap incoming webhook message length (LC-74)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 2m31s
Create release / Create release from merged PR (pull_request) Has been skipped
f229545458
The public POST /webhook/{secret} endpoint is unauthenticated, so bound a single message to 16 KiB (generous for alerts) and return 400 on oversize, rather than relying solely on Axum's 2 MiB default body limit. New test covers the 400.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nrupard deleted branch feat/lc-74-incoming-webhooks 2026-05-21 15:34:19 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!170
No description provided.