Skip to content

DR-134 — An RSS feed authenticates by URL-borne token, not by session or header

DR-134 — An RSS feed authenticates by URL-borne token, not by session or header

Section titled “DR-134 — An RSS feed authenticates by URL-borne token, not by session or header”

app/api/feeds/[workspaceId]/rss/route.ts and its /filtered/ sibling were unauthenticated: createServiceClient() plus proxy.ts’s isApiRoute check, which exempts all of /api/** from the auth redirect. id-166 recorded that posture as intentional and left the mechanism open. Per DR-123 that is evidence of intent at the time, never of correctness — and the owner has now ruled it an oversight rather than a design.

Three measurements shaped the ruling.

Session auth would break the feature, but “therefore public” does not follow. Probed with an empty cookie store, getAuthorisedClient(['admin','editor']) returns {success:false} and the sibling articles route returns 401. Feed readers cannot perform a cookie login, so the sibling pattern would 401 every consumer. But this codebase already authenticates without cookies: lib/mcp/auth.ts:createMcpUserClient works from an Authorization: Bearer header, and lib/cron-auth.ts gates two more boundaries on a bearer secret. Cookie auth breaks RSS; authentication does not.

Header credentials are bypassable here, and this is the non-obvious constraint. Both routes emit Cache-Control: public, max-age=900, s-maxage=900, and they are the only two API routes in the app emitting public/s-maxage, with no /api/* override in vercel.json. s-maxage is the shared-CDN directive, so Vercel’s edge can serve these responses without invoking the handler at all. A credential in a header is not part of the CDN cache key, so a cached feed could be served to an unauthenticated caller — a bypass no handler-side code could catch. A URL-borne secret is in the cache key.

The workspace UUID is already a bearer credential, and an unrotatable one. It sits in /intelligence/[workspaceId] page URLs, browser history and Referrer headers. It cannot be revoked without destroying the workspace. The /filtered/ feed is the sharper exposure: relevance_reasoning on rejected articles is the AI explaining the client’s evaluation criteria, and near-miss scores map the threshold.

RSS feeds authenticate by workspace-scoped feed token carried in the URL. The routes move to app/api/intelligence/workspaces/[id]/rss/{route,filtered/route}.ts. The relocation landed at 28c89ba2f (behaviour unchanged); the token mechanism is specced under id-166.

Relocation confers no auth by itself — proxy.ts exempts the whole namespace, so every route there gates itself in its own handler. The two changes are independent.

Signed URLs (HMAC over workspace id + expiry). Cheaper — no schema, one env secret. Rejected because expiry is the security model and it fights the use case: a reader polling for months needs a long-lived URL, so you either re-issue constantly or set an expiry so distant it stops being a control. It also gives no revocation.

Public plus rate limiting. Rejected on evidence, not taste. lib/rate-limit.ts is in-memory and per-serverless-instance — its own header says it targets accidental request loops, not adversarial abuse — all 10 existing call sites key on user.id, which an unauthenticated route lacks, and with s-maxage=900 the CDN absorbs the traffic the limiter would police. It also does not address disclosure, which is the concern the filtered feed actually raises.

Leave under /api/feeds/ for auditability. The argument was that a separate top-level namespace makes the unauthenticated surface visible at a glance. Rejected: the property is a naming convention, not an enforcement point, and it trades a real auth gap for a readability cue.

  • components/intelligence/rss-feed-panel.tsx currently builds feed URLs client-side from window.location.origin. It must instead render an issued token URL, so the panel gains an issue/revoke surface.
  • A token table is needed (workspace_id, token hash, label, created/revoked/last_used) with a display-once issue flow.
  • createServiceClient() stays load-bearing regardless of mechanism: measured on Platform staging, anon holds zero grants on feed_articles, feed_sources, workspaces and application_types, so an anon client returns 404 for every workspace.
  • Open, and deliberately not assumed: do mainstream feed readers and intranet embed widgets accept a long-lived credential in the URL path or query, and which can send a custom Authorization header? Not executed against any real reader. The CDN finding independently favours URL-borne secrets, but the carrier should be settled by test before it is fixed.
  • Separately surfaced, not ruled here: no table carrying workspace_id has an RLS isolation predicate — five are USING (true) for authenticated and the facet tables only assert the parent row exists. Isolation is entirely handler-side. Bounded by anon holding no grants, so the gap is authenticated-to-authenticated.