PRODUCT — ID-166 RSS feed auth
PRODUCT — ID-166 RSS feed auth
Section titled “PRODUCT — ID-166 RSS feed auth”{166.2} of the id-166 spec chain. Grounded by RESEARCH.md ({166.1});
implementation and validation belong in TECH.md ({166.3}).
Summary
Section titled “Summary”The two intelligence RSS feeds — passed articles and their /filtered/ sibling —
currently serve workspace-scoped business data to anyone holding a workspace
UUID. They gain workspace-scoped feed tokens carried in the URL (DR-134):
an operator issues a labelled token, pastes the resulting URL into a feed reader,
and can revoke it later without destroying the workspace.
Problem
Section titled “Problem”The workspace UUID is already functioning as a bearer credential, and a bad one:
it sits in /intelligence/[workspaceId] page URLs, browser history and Referer
headers, and it cannot be rotated or 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. The product’s own UI already half-acknowledges
this with a competitor warning on that row.
gitnexus orientation. Both feed handlers resolve as standalone definitions —
app/api/intelligence/workspaces/[id]/rss/route.ts:GET (26–109) and
.../rss/filtered/route.ts:GET (29–109) — each participating in zero
execution flows, consistent with unwrapped endpoints nothing internal calls.
RssFeedPanel (components/intelligence/rss-feed-panel.tsx:111-152) has 1
direct caller, WorkspaceOverviewPage (app/intelligence/[workspaceId]/page.tsx),
and also participates in zero processes.
A measured caveat that must not be read past: the graph shows no edge
between RssFeedPanel and either route, although the panel demonstrably
constructs both URLs at :121-122. GitNexus cannot see template-literal URL
construction, and route_map/api_impact independently report consumers: []
for both routes. Do not size this change from GitNexus consumer counts — every
consumer claim in the spec chain comes from grep plus file reads.
Goals / Non-goals
Section titled “Goals / Non-goals”Goals. Authenticate both feeds without breaking real feed readers; give operators revocation and per-subscriber attribution; make revocation actually take effect.
Non-goals. Rate limiting (DR-134 rejected it as this task’s mechanism on
measured grounds; id-225 owns the limiter’s real fix). Narrowing proxy.ts’s
isApiRoute exemption — it touches all 147 route files and belongs to its own
task. Any change to how articles are scored, filtered or ingested.
Behavior
Section titled “Behavior”Subscribing and reading a feed
Section titled “Subscribing and reading a feed”-
Each feed URL carries a feed token. A request bearing a valid, unrevoked token for the workspace named in the URL returns the feed exactly as it does today:
200,Content-Type: application/rss+xml; charset=utf-8, and a valid RSS 2.0 document with<rss>and<channel>elements. -
A request with no token returns
401. It must never return an empty but well-formed feed. This preserves the existing deliberate posture atrss/route.ts:62-70— feed readers do not retry a200, so a soft failure silently empties every subscriber’s feed instead of alerting anyone. -
A request whose token is unknown, malformed, or revoked returns
401, indistinguishable from each other to the caller. The response body carries no detail about which condition applied. -
A request whose token is valid but belongs to a different workspace than the one named in the URL returns
401, not403and not the other workspace’s feed. A token authorises exactly one workspace’s feeds. -
The token authorises both feeds of its workspace. A single token works for the passed feed and the
/filtered/feed. Open question (OQ-7): whether the sharper/filtered/exposure warrants a separate token scope is unresolved; if it does, this invariant splits. -
A request for a workspace that does not exist, or that is not an intelligence workspace, returns
404— as today. Token validity is checked before workspace existence, so a caller without a valid token cannot use the 404/401 distinction to probe which workspace UUIDs exist. -
A valid token whose workspace has no matching articles returns a
200feed with a valid<channel>and zero<item>elements — an empty feed, not an error. This is the normal state of a new workspace. -
A database or upstream error returns
500, never an empty200. Existing behaviour, preserved. -
The token must survive the round trip verbatim. Whatever carrier is chosen, a token that a feed reader stores and replays must arrive at the handler byte-identical. Two known hazards, both of which must be closed rather than assumed away: the app’s own query parsing splits comma-separated values into arrays, and no feed reader documents whether it normalises, reorders, strips or truncates query parameters, or preserves them across a redirect (
RESEARCH.mdOQ-1, stillUNDECIDABLE). -
If the feed document emits a self-referencing link (
<atom:link rel="self">), that link must either carry the token or be omitted. A feed that publishes its own credential in its body is a leak, and any reader rendering the feed would expose it.
Revocation
Section titled “Revocation”-
Revoking a token stops it working — observably, from the subscriber’s side. Within the product’s stated revocation window, a revoked token’s URL returns
401. This is the invariant the whole mechanism exists for:DR-134chose workspace-scoped tokens over signed URLs precisely because they can be revoked. -
Revocation is not complete when the database row is marked revoked. These responses are served from a shared CDN for up to 15 minutes, and the cache knows nothing about the revocation. A revoked token that keeps serving a cached feed is the primary failure mode of this feature, and it is silent — the operator sees the token disappear from the UI while the URL keeps working. The product requirement is that revocation takes effect for the subscriber, not merely in the database.
-
Revoking a token does not affect any other token for the same workspace, and does not affect the workspace, its sources, or its articles.
-
A revoked token is never reusable. Re-issuing produces a new token; the revoked value stays permanently invalid.
Issuing and managing tokens
Section titled “Issuing and managing tokens”-
Feed tokens are managed from the intelligence workspace overview, in the existing RSS feed panel (
app/intelligence/[workspaceId]/page.tsx). An operator can see the workspace’s tokens, issue a new one, and revoke an existing one without leaving the page. -
A label is required at issue time. The operator names what the token is for (“Marketing team Feedly”, “Ops intranet dashboard”). This is not a nicety: revocation is the point of the mechanism, and an unlabelled list of six identical rows is one nobody dares revoke, which defeats the decision.
-
The full token URL is displayed exactly once, at the moment of issue, with a copy affordance. After the operator navigates away or dismisses the display, the URL is not retrievable — the product can never show it again.
-
The token list shows, per token: the label, the date created, the date last used, and a truncated non-secret fragment of the token (e.g.
cfeed_…a9F2) sufficient to tell two tokens apart. It never shows the full token or a working URL. -
“Last used” is explicitly approximate and labelled as such in the UI. Because most polls are served from the CDN without reaching the handler, this field records only cache misses and systematically under-reports. An operator who revokes a token believing it unused, on the strength of an under-reporting field, is a foreseeable and destructive mistake — the UI must not invite it.
-
Multiple tokens can exist for one workspace simultaneously, which is what makes per-subscriber attribution possible: one token per consumer, revocable independently.
-
Issuing a token does not revoke or invalidate any existing token. There is no implicit rotation.
-
Empty state: a workspace with no tokens shows an explicit empty state explaining that the feeds are unreachable until a token is issued — not a blank panel, and not a feed URL the operator might paste and find broken.
-
Loading and pending states: issue and revoke both show a pending state and disable the triggering control while in flight, so a double-click cannot mint two tokens or fire two revocations. On failure, the operator sees an error and the list is unchanged — never a partially-updated list.
-
Race between operators: if a token is revoked by one operator while another is viewing the list, the second operator’s next action on that token fails cleanly with a message explaining it no longer exists, rather than appearing to succeed.
-
Viewing the token list — labels, dates, truncated fragments — follows the same role gate as the rest of the intelligence workspace overview.
-
Issuing and revoking are restricted to the roles that already administer the workspace’s intelligence configuration. Open question: the sibling
articlesroute in this namespace gates on['admin','editor'], and editors already manage feed sources, which argues for matching it; against that, a feed token grants standing read access to workspace data, which argues for admin-only. Recommend['admin','editor']for consistency with the sibling, and flag it for owner confirmation rather than deciding it here. -
A user without the issuing role sees the token list in read-only form, with the issue and revoke controls absent rather than present-and-disabled.
The panel’s existing copy becomes false
Section titled “The panel’s existing copy becomes false”-
Three pieces of copy in the RSS feed panel assert the feeds are unauthenticated and must change in the same release that lands auth, otherwise the UI actively misleads:
- the
'Public'and'Public — internal use only'badges (:54); - the sensitive-row warning (
:101-105); - the caption at
:145-150: “No authentication is required, so treat the filtered feed URL as confidential.”
- the
-
Confidentiality guidance stays, with its reason replaced. The URL is still a credential and still must be treated as one — but because it is the credential, not because there is no authentication. The panel should say so.
-
The token URL is rendered inside an app page, which is where it actually leaks. The W3C TAG’s capability-URL guidance names third-party scripts on a page reached via the URL as a live vector, and it applies to the issue display, not to the feed itself (a feed is fetched by a reader, not rendered as a page). The display-once surface must not link out to third-party sites from the same view, and the token must not be placed anywhere a third-party script on that page could read it.
Presentation invariants
Section titled “Presentation invariants”-
All colour, spacing and typography come from Warm Meridian semantic tokens — no raw Tailwind colours (
design/warm-meridian-implementation-spec.md). -
Never colour alone for meaning (WCAG 2.1 AA). A revoked token, an active token, and the display-once warning must each be distinguishable by text or icon as well as colour.
-
The copy affordance is keyboard-reachable, and after issuing a token focus moves to the display-once surface so a keyboard or screen-reader user is not stranded on a control whose result they cannot perceive.
-
UK English throughout, DD/MM/YYYY dates.
Open questions
Section titled “Open questions”Carried from RESEARCH.md; each is unresolved and none should be treated as
settled by this spec.
- OQ-1 — do mainstream feed readers preserve a URL-borne credential intact
through normalisation and redirects?
UNDECIDABLE; needs subscribe-and-observe against real readers before the carrier is fixed (DR-134requires this). - OQ-1b — does a feed added to Feedly become discoverable to other Feedly users through its search or discovery surfaces? If yes, a tokenised URL in Feedly is a public capability URL, which would materially change this design.
- OQ-3 — table and column naming, given
DR-134says “workspace-scoped” while the entity glossary marks workspace the legacy tier. - OQ-7 — do both feeds need the same posture, or does
/filtered/warrant a separate scope? (Invariant 5.) - Role gate —
['admin','editor']or admin-only for issue/revoke? (Invariant 26.)