feat(client): per-room RSS/Atom + iCal feeds, token-gated (LC-102) #232

Merged
nrupard merged 1 commit from feat/lc-102-room-feeds into main 2026-05-27 17:06:39 +02:00
Owner

A read-only RSS/Atom + iCal feed per room so external tools (RSS readers, calendar apps) can follow a room without a session. The token in the feed URL is the only credential - same posture as the LC-74 incoming webhook.

  1. Feed payload: message text + a permalink back to each message; no media enclosures.
  2. iCal heuristic: a VEVENT only for polls (LC-66) that carry a closes_at; no free-text date-phrase parsing.
  3. Per-user cross-room mentions feed: out of scope here (tracked with the LC-82 activity center).

What lands

  • Schema (chat.db room_feeds, migration 0053): room_id (FK cascade), kind CHECK rss/ical, token_hash UNIQUE, created_by, created_at, last_fetched_at, revoked_at. Only the HMAC of the token is stored (crate::auth::hash_api_token), so the feature is gated on LETS_CHAT_SECRET_KEY, like webhooks.
  • Public routes (unauthenticated, merged after the TraceLayer so the token never logs):
    • GET /feed/rss/{token} -> Atom 1.0 of the room's recent messages (text + permalink), application/atom+xml.
    • GET /feed/ical/{token} -> VCALENDAR with one VEVENT per scheduled poll, text/calendar.
  • Permission-reflecting: each fetch re-resolves the feed's creator and re-checks their current room access (is_room_accessible). A revoke, kick, room delete, or deleted account -> 410. Unknown token, or a token used on the wrong kind's path -> 404.
  • Cache-friendly: ETag fingerprints the rendered set; a matching If-None-Match -> 304. Cache-Control: private, max-age=300.
  • Escaping: Atom XML-escaped; iCal RFC 5545 TEXT-escaped with CRLF.
  • Admin surface: /room/{id}/feeds (moderator-gated) mints an RSS or iCal feed and reveals the URL once, lists feeds (kind / created / last-fetched / status), and revokes; linked from the room moderators page.
  • GET-only: no write surface on the feed routes.

Acceptance criteria

  • Admin mints an RSS or iCal token from room settings; URL shown once.
  • Fetching returns a valid Atom or iCal document.
  • Revoke -> subsequent fetches 410.
  • Feed reflects the issuing user's read permissions (kicked / deleted-account creator -> 410).
  • ETag set; conditional GET returns 304 when nothing changed.
  • No write surface; GET-only.

Verification

server/tests/routes_feeds.rs (7 tests): mint -> valid Atom/iCal, revoke -> 410, creator-without-access -> 410, deleted-creator -> 410, wrong-kind/unknown -> 404, conditional GET -> 304. just test + just test-saas green; cargo fmt --all + clippy clean.

A read-only RSS/Atom + iCal feed per room so external tools (RSS readers, calendar apps) can follow a room without a session. The token in the feed URL is the only credential - same posture as the LC-74 incoming webhook. ## Spec-review choices (the three recommended defaults, confirmed) 1. **Feed payload:** message text + a permalink back to each message; no media enclosures. 2. **iCal heuristic:** a VEVENT only for polls (LC-66) that carry a `closes_at`; no free-text date-phrase parsing. 3. **Per-user cross-room mentions feed:** out of scope here (tracked with the LC-82 activity center). ## What lands - **Schema** (`chat.db` `room_feeds`, migration 0053): `room_id` (FK cascade), `kind` CHECK `rss`/`ical`, `token_hash` UNIQUE, `created_by`, `created_at`, `last_fetched_at`, `revoked_at`. Only the HMAC of the token is stored (`crate::auth::hash_api_token`), so the feature is gated on `LETS_CHAT_SECRET_KEY`, like webhooks. - **Public routes** (unauthenticated, merged after the TraceLayer so the token never logs): - `GET /feed/rss/{token}` -> Atom 1.0 of the room's recent messages (text + permalink), `application/atom+xml`. - `GET /feed/ical/{token}` -> `VCALENDAR` with one `VEVENT` per scheduled poll, `text/calendar`. - **Permission-reflecting:** each fetch re-resolves the feed's creator and re-checks their current room access (`is_room_accessible`). A revoke, kick, room delete, or deleted account -> **410**. Unknown token, or a token used on the wrong kind's path -> **404**. - **Cache-friendly:** `ETag` fingerprints the rendered set; a matching `If-None-Match` -> **304**. `Cache-Control: private, max-age=300`. - **Escaping:** Atom XML-escaped; iCal RFC 5545 TEXT-escaped with CRLF. - **Admin surface:** `/room/{id}/feeds` (moderator-gated) mints an RSS or iCal feed and reveals the URL once, lists feeds (kind / created / last-fetched / status), and revokes; linked from the room moderators page. - **GET-only:** no write surface on the feed routes. ## Acceptance criteria - [x] Admin mints an RSS or iCal token from room settings; URL shown once. - [x] Fetching returns a valid Atom or iCal document. - [x] Revoke -> subsequent fetches 410. - [x] Feed reflects the issuing user's read permissions (kicked / deleted-account creator -> 410). - [x] `ETag` set; conditional GET returns 304 when nothing changed. - [x] No write surface; GET-only. ## Verification `server/tests/routes_feeds.rs` (7 tests): mint -> valid Atom/iCal, revoke -> 410, creator-without-access -> 410, deleted-creator -> 410, wrong-kind/unknown -> 404, conditional GET -> 304. `just test` + `just test-saas` green; `cargo fmt --all` + clippy clean.
feat(client): per-room RSS/Atom + iCal feeds, token-gated (LC-102)
All checks were successful
check-secrets / Nosey parker (push) Successful in 8s
check-secrets / Kingfisher (push) Successful in 8s
check-secrets / TruffleHog (push) Successful in 8s
check-secrets / Nosey parker (pull_request) Successful in 3s
check-secrets / Kingfisher (pull_request) Successful in 4s
check-secrets / TruffleHog (pull_request) Successful in 5s
Check / clippy + fmt + tests (pull_request) Successful in 1m49s
Create release / Create release from merged PR (pull_request) Has been skipped
6cfc333e89
A read-only feed per room so external tools (RSS readers, calendar apps) can follow a room without a session. The token in the feed URL is the only credential, mirroring the LC-74 incoming-webhook posture.

Schema: chat.db room_feeds (migration 0053) - room_id (FK cascade), kind CHECK rss/ical, token_hash UNIQUE, created_by, created_at, last_fetched_at, revoked_at. Only the HMAC of the token is stored (crate::auth::hash_api_token), so the feature is gated on LETS_CHAT_SECRET_KEY just like webhooks.

Public routes (unauthenticated, merged after the TraceLayer so the token never logs): GET /feed/rss/{token} returns an Atom 1.0 document of the room's recent messages (message text + a permalink back to each message; no media); GET /feed/ical/{token} returns a VCALENDAR with one VEVENT per poll that carries a closes_at. Each fetch re-resolves the feed's creator and re-checks their current room access (db::chat::is_room_accessible), so a revoke, a kick, a room delete, or a deleted account makes the feed return 410. An unknown token, or a token used on the wrong kind's path, returns 404. ETag fingerprints the rendered set; a matching If-None-Match returns 304. Atom is XML-escaped, iCal is RFC 5545 TEXT-escaped with CRLF.

Admin surface: /room/{id}/feeds (moderator-gated) mints an RSS or iCal feed and reveals the URL once, lists feeds with last-fetched + status, and revokes; linked from the room moderators page next to webhooks / email-inboxes.

Spec-review choices taken (the three recommended defaults): message text + permalink only (no media enclosures); iCal VEVENTs come only from polls with a closes_at (no free-text date parsing); the per-user cross-room mentions feed is out of scope (tracked with LC-82).

Tests: server/tests/routes_feeds.rs covers mint -> valid Atom/iCal, revoke -> 410, creator-without-access -> 410, deleted-creator -> 410, wrong-kind/unknown -> 404, and conditional GET -> 304. just test + just test-saas green; cargo fmt --all + clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nrupard deleted branch feat/lc-102-room-feeds 2026-05-27 17:06:39 +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!232
No description provided.