Skip to content

RESEARCH — ID-166 RSS feed auth

{166.1} of the id-166 spec chain. Grounding only — behaviour belongs in PRODUCT.md, implementation in TECH.md.

The mechanism is already decided and is not re-litigated here. DR-134 (accepted 2026-08-08) rules: an RSS feed authenticates by a workspace-scoped token carried in the URL, not by session or header. This document grounds that ruling: what the token attaches to, what it costs, and what the ruling did not foresee.

Measurement base. All code and DB findings are measured on main at f8781bb68 (10/08/2026). All SQL is SELECT-only against Platform staging rbwqewalexrzgxtvcqrh. Platform prod and every client DB are unchecked — policy and grant drift between projects is plausible and unmeasured. Pre-launch, all Platform data is synthetic, so no claim below rests on row population in either direction.


app/api/intelligence/workspaces/[id]/rss/route.ts and .../rss/filtered/route.ts are near-identical twins (111 lines each), differing only in a Zod default (50 vs 20), the passed predicate (true vs false), the order clause (ingested_at desc vs relevance_score desc) and the channel title/description.

  • No wrapper, no middleware, no auth call of any kind. Bare export async function GET(request, context)rss/route.ts:27, filtered/route.ts:30.
  • createServiceClient() at rss/route.ts:30 / filtered/route.ts:33.
  • Workspace lookup is gated on the intelligence discriminator: .eq('application_types.key','intelligence') (rss/route.ts:42-44). Failure returns 404 as plain text, not JSON.
  • Article isolation is only .eq('workspace_id', workspaceId) (rss/route.ts:57).
  • The error path is deliberate and documented: a DB error returns 500, never an empty 200 (rss/route.ts:62-70) — the comment explains feed readers do not retry a 200. Preserve this on the auth path: an auth failure must be a clean 401, never an empty feed.
  • Response headers: Content-Type: application/rss+xml; charset=utf-8 and Cache-Control: public, max-age=900, s-maxage=900 (rss/route.ts:99-105).

A token check has a clean insertion point — immediately after await context.params, before createServiceClient(). Both routes already parse search params via parseSearchParams (lib/validation.ts:95), so a ?token= needs only a schema field, no plumbing.

The s-maxage uniqueness claim — challenged with two instruments, survives

Section titled “The s-maxage uniqueness claim — challenged with two instruments, survives”

DR-134’s carrier choice rests on these being the only API routes emitting a shared-CDN directive. Re-measured, deliberately trying to break it:

  1. Explicit emission, repo-wide (not just app/): s-maxage returns exactly 4 hits — the two routes (:103 each) and their two tests. Every other Cache-Control in the app is private or non-cacheable (no-cache, no-store, private, max-age=30).
  2. Implicit CDN caching via route segment config, which a header grep cannot see: no revalidate export anywhere in app/. No route acquires an implicit s-maxage from Next’s segment config.

vercel.json has no /api/* override (its Cache-Control entries cover only _next/static, images, fonts and robots/sitemap/manifest), and next.config.ts has no headers() function at all. The claim holds.

Two constraints on “intranet embed” that DR-134 does not record

Section titled “Two constraints on “intranet embed” that DR-134 does not record”

Both bear directly on the open carrier question, and neither was known when the ruling was made:

  • No CORS anywhere. Access-Control-Allow returns zero hits across app/, lib/, vercel.json and next.config.ts. A browser-side intranet widget doing a cross-origin fetch() would be blocked. Server-side embeds and native feed readers are unaffected.
  • Framing is globally denied. vercel.json sets X-Frame-Options: DENY and CSP frame-ancestors 'none' on /(.*). No Canonical URL can be iframed.

Neither disturbs DR-134’s ruling, but together they mean “intranet embed widget” can only mean a server-side fetch. That narrows the carrier question materially.

components/intelligence/rss-feed-panel.tsx (153 lines), rendered at exactly one site (app/intelligence/[workspaceId]/page.tsx:103).

  • The lines to replace: :116-119 derives baseUrl from window.location.origin; :121 builds passedUrl; :122 builds filteredUrl. All plain template literals — no server round-trip, nothing to hook.
  • RssFeedPanelProps is {workspaceId, workspaceName} (:9-12) — the component receives no token-bearing data and does no fetching. An issued-token URL needs either new props from the server component or a new query.
  • A display-once flow collides with three existing affordances: the URL is rendered in a <code> (:58-60), a clipboard copy button (:61-74) and an open-in-new-tab anchor (:75-90). Today the URL is a pure derivation, re-rendered identically on every mount.
  • Copy that becomes false on the day auth lands: the 'Public' / 'Public — internal use only' badges (:54), the sensitive-row warning (:101-105), and the caption at :145-150 stating “No authentication is required, so treat the filtered feed URL as confidential.”

Neither is recorded in the task file:

  • e2e/tests/intelligence-workflow.spec.ts:~160 and :192page.request.get() against both feeds asserting status === 200. These are the closest thing to an integration test of the feed contract, and a token-gated route 401s them regardless of the Playwright session, because DR-134 rules out cookie auth.
  • 13 route tests across __tests__/app/api/intelligence/workspaces/[id]/rss/{route,filtered/route}.test.ts (the journal’s “12” is now 8 + 5, read from describe/it blocks, not from a run), plus 9 panel tests in __tests__/components/intelligence/rss-feed-panel.test.tsx. Several panel tests assert the exact copy above (:105, :149-151, :154-166) and will need rewriting, not extending. None assert the URL string, so the URL shape is unpinned by tests.

Auth patterns — build-on vs resolve-first

Section titled “Auth patterns — build-on vs resolve-first”
PatternCallWhy
lib/mcp/auth.ts:createMcpUserClient (:23-38)Do not build onHeader-carried (DR-134 rules it out) and it authenticates a user, passing a JWT so auth.uid() resolves. A feed token is not a user and maps to no auth.uid(). Right instinct, wrong mechanism.
lib/cron-auth.ts (:16-26, :46-56)Build on the shape, not the codeCopy: stateless verify returning boolean, and fail-closed when the secret is unset (:20-23). Cannot extend: header not URL; one global env secret (no scoping, revocation or attribution — exactly what DR-134 chose (b) to get); and authHeader === \Bearer ${secret}“ is a plain string compare, not constant-time.
lib/api/define-route.tsDo not wrapshouldValidateResponseBody (:146-151) validates only 2xx application/json. RSS is application/rss+xml, so the wrapper is a pure no-op here. Zero behaviour, one more layer.
getAuthorisedClient() (lib/auth/client.ts:86-113)Split callFeed routes: no — confirmed cookie-only (:89createClient()auth.getUser()). Issue/revoke routes: yes, this IS the canonical pattern{success:boolean} + authFailureResponse(auth) (:124-147). Exemplar to copy verbatim: app/api/oauth/revoke/route.ts:22-23.
proxy.ts (:111, :113)Do not build on; do not narrow in this taskSee Open Question OQ-4.
components/settings/connected-apps-section.tsx + app/api/oauth/revoke/route.tsBuild on shape, not fetchingThe nearest “list credentials, revoke one” surface. But it stores nothingrevoke/route.ts:30-32 delegates to supabase.auth.oauth.revokeGrant(), so there is no app-owned credential-storage precedent to inherit. It also uses raw fetch(), off-convention against the TanStack-Query-exclusive rule.
lib/corpus/writer-fence.tsShape reference only; reject as credential modelThe closest existing “mint opaque token, persist, compare on use”. Reject on two counts: it stores the token in plaintext (corpus_writer_fence_lease.holder_token), and a v4 UUID is not credential-grade entropy.

A resolve-first signal on cron-auth.ts worth carrying: verifyCronAuth has only 2 callers, while three other cron routes hand-roll the identical comparison inline (cron/datapath-watch/route.ts:115-117, cron/intelligence-cleanup/route.ts:10-12, cron/intelligence-poll/route.ts:11-13). The shared bearer-verify helper exists and its own domain half-ignores it. If id-166 adds a verifyFeedToken, that history is the argument for pairing it with route-level tests that prove the 401, rather than trusting convention.

createServiceClient() is load-bearing, and it inverts where the security effort goes

Section titled “createServiceClient() is load-bearing, and it inverts where the security effort goes”

lib/supabase/server.ts:60-72 uses SUPABASE_SERVICE_ROLE_KEY and spreads DB_OPTION, so it reads as service_role and bypasses RLS entirely.

Therefore a feed_tokens table’s RLS is not the enforcement point for feed verification. Token verification is handler-enforced and must be tested as such. RLS on that table matters only for the operator issue/revoke path (cookie client). Design the policies for the operator path; do not mistake them for the feed’s guard.

Crypto and table inventory — challenged, S541 holds, with two refinements

Section titled “Crypto and table inventory — challenged, S541 holds, with two refinements”
  • Signing utilities are genuinely net-new. createHmac, timingSafeEqual, randomBytes, scrypt, pbkdf2, bcrypt, argon2zero hits across lib/ and app/.
  • Refinement 1 — SHA-256 hashing is NOT net-new. Two established in-repo idioms: lib/upload/folder-drop.ts:384 (createHash('sha256').update(bytes).digest('hex')) and app/api/procurement/[id]/responses/draft-all/route.ts:122-127 (crypto.subtle.digest('SHA-256', …) + hex encode). DR-134’s “both stronger options are net-new” is pessimistic on option (b)‘s cost — only mint and constant-time compare are new.
  • Refinement 2 — crypto.randomUUID() is the established minting call (9 sites, incl. proxy.ts:45). It is not adequate token entropy, and the spec must say so explicitly, because it is the path of least resistance and it is wrong.
  • No token/api_key/share table — challenged three ways, holds all three. Migration filenames: zero. Migration bodies: zero. Live staging catalogue across public, api, auth, storage: only GoTrue internals (auth.one_time_tokens, auth.refresh_tokens, auth.webauthn_credentials). Every column hit is a false positive (*_tokens LLM counters, source_documents.content_hash, the plaintext fence lease).

Cited exemplar: supabase/migrations/20260716113306_id147_form_attachments.sql — the most recent CREATE TABLE + RLS, and it uses the stricter no-anon-grant posture a credential table wants. Mechanics live in supabase/CLAUDE.md; the load-bearing ones for this task:

  • DDL via CLI only (supabase migration new + db push, foreground). Never MCP apply_migration / execute_sql.
  • cat supabase/.temp/project-ref before every push — the link drifts to prod silently.
  • Stamp against the REMOTE schema_migrations max, non-round (both S481 collisions were round timestamps). Verify with to_regclass, never supabase migration list parity.
  • Every policy MUST name its roles explicitly (TO authenticated, TO service_role) — the id-347 lesson, and supabase/CLAUDE.md records that no CI gate covers it. See Issue I1.
  • RLS enable is belt-and-braces: 20260726120000_ensure_rls_event_trigger.sql:44-49 auto-enables RLS on every CREATE TABLE in public, but the convention is still to write it explicitly.

The RLS isolation gap, measured — and it is worse than the journal records

Section titled “The RLS isolation gap, measured — and it is worse than the journal records”

Executed a join over pg_class/pg_policy/pg_namespace returning relrowsecurity, polname, polcmd, polpermissive, resolved polroles, and pg_get_expr of both polqual and polwithcheck, for every public table carrying workspace_id; then a raw-polroles disambiguation query; then information_schema.role_table_grants.

  • 10 tables carry workspace_id. RLS enabled on all 10; relforcerowsecurity false on all 10.
  • S541’s “five are USING (true) for authenticated” is exactly rightfeed_articles, feed_prompts, feed_sources, pipeline_runs, si_processing_queue.
  • Extending it: the feed’s entire read path is unpredicated. workspaces itself (missed by a workspace_id sweep — it keys on id) has workspaces_select TO authenticated USING (true), and application_types_select_all is TO authenticated, service_role USING (true). All four tables the RSS routes touch are USING (true) for authenticated.
  • NEW, unreported by S541 — the live id-347 anti-pattern. The five facet tables (intelligence_workspaces, competitor_research_workspaces, product_guide_workspaces, sales_proposal_workspaces, training_onboarding_workspaces) have all 20 of their policies targeting PUBLICpolroles is literally {0}, i.e. authored with no TO clause. supabase/CLAUDE.md names this exact failure as the real id-347 exposure. Their predicates are the weak parent-existence check EXISTS (SELECT 1 FROM workspaces w WHERE w.id = <t>.workspace_id), which isolates nothing. S541 characterised the predicate correctly but did not report the role targeting.
  • anon grants — challenged and extended, holds. S541 measured public only; the app’s Data API is schemas = ["api"], so the anon-reachable surface is really the api views. Measured both: zero anon grants in public AND zero in api, across all 12 relevant tables. The gap is authenticated-to-authenticated, and the PUBLIC-targeted facet policies are bounded by the same missing grant.

Would a feed_tokens table inherit the gap? Yes by default — and here it matters more than usual, because RLS is the only isolation the operator path has. feed_tokens_select TO authenticated USING (true) would let any authenticated tenant user enumerate every workspace’s token rows. This is the table where the USING (true) house style must stop.

lib/rate-limit.ts — DR-134’s characterisation holds; its figure does not.

  • In-memory Map at :22; per-serverless-instance, stated at :5; targets accidental loops not adversaries, stated :6-7. All confirmed.
  • Call sites: 33, not 10. DR-134 and the id-166 journal both say “all 10 existing call sites”. The real count is 33 across app/, and 33 of 33 interpolate ${user.id} — read individually, including the 11 that wrap the key onto a following line. The figure is understated by 23 and the argument it supports is strengthened, not weakened. No re-litigation implied; flagged because “10” sits in a ratified DR and will be re-cited.
  • A stale premise to carry: :6 justifies the design as “acceptable for a single-user system where auth gates all routes.” That is false today (multi-workspace, multi-role, user_roles with admin/editor/viewer). Per DR-123 the comment is evidence of intent at authoring time, not of correctness.

Each carries an explicit resolve-first vs build-on call.

#IssueCall
I120 policies across the 5 facet tables target PUBLIC (no TO clause) — the live id-347 anti-pattern, with no CI gate covering itNot resolve-first for id-166 (bounded by zero anon grants; out of scope). But the spec must not copy the house style. Owner-flagged: worth its own task.
I2The feed’s whole read path is USING (true); isolation is 100% handler-side .eq('workspace_id', …)Build on — unchanged by this task. But feed_tokens must carry a real predicate, since RLS is the operator path’s only guard.
I3verifyCronAuth exists; 3 of 5 cron routes hand-roll it inline anywayBuild on the shape, not the code. Direct evidence that verifyFeedToken needs route-level 401 tests, not convention.
I4No CORS headers anywhere; global X-Frame-Options: DENY + frame-ancestors 'none'Owner-flagged — constrains what “intranet embed” can mean, and intersects the open carrier question.
I5SURFACE_TABLES (scripts/generate-api-views.ts:121) is an explicit allowlist, but every client routes .from('x')api.x via DB_OPTIONResolve-first — a decision, not code. See OQ-2.
I6rate-limit.ts:6’s “single-user system” premise is falseDo not build on. No action for id-166 beyond not reintroducing rate limiting as a sub-component — DR-134 already rejected it as the mechanism, and the limiter is still per-instance while the CDN still absorbs repeats.
I7connected-apps-section.tsx uses raw fetch() against the TanStack-Query-exclusive ruleBuild on shape, not fetching. New issue/revoke UI belongs in lib/query/.
I8defineRoute would be a no-op on application/rss+xmlDo not build on — leave both routes unwrapped.

The naming tension — owner judgement required

Section titled “The naming tension — owner judgement required”

DR-134 names the mechanism “workspace-scoped feed tokens”. reference/entity-glossary.md:15 marks workspace as the LEGACY tier (DR-038, S452), “removed from the containment chain”, with “no new *_workspaces tables are ever minted”; :19 adds that workspace was never the tenant.

Requirement-first: the requirement is “scope a feed to one intelligence activity”, and its current source is live — the routes join workspaces → application_types.key='intelligence' (rss/route.ts:42-44), intelligence_workspaces is the live facet, and /intelligence/[workspaceId] is the live page. ID-145 W1 dropped only the procurement workspace-keyed surfaces; the intelligence ones were untouched. So workspace_id is today the only carrier of that scope for intelligence.

Research call: build on workspace_id. A feed_tokens table with a workspace_id FK is not a *_workspaces facet table, so it does not breach the glossary’s letter. But DR-134’s wording is in tension with a ratified glossary entry, and under DR-104/DR-106 the ratified doc outranks code. The owner should confirm the table and column naming before the migration is written — naming it for what it scopes (the intelligence feed) rather than for the workspace makes the eventual rename cheap.

That spec is marked [CURRENT-CANONICAL] but was last verified 20/05/2026, sources most of its substrate from phase-0-investigation/ — a family DR-106 names stale — and descends from id-31. Per DR-106, “any spec or invariant belonging to a task id below ~130 is presumed stale.” Cite it as history, not authority.


From the domain skills colocated with the code this task touches.

app/api/.claude/skills/api-and-interface-design:

  • Hyrum’s Law“every public behavior, including undocumented quirks, error message text, timing and ordering, becomes a de facto contract.” Directly relevant: the current feed URL shape is unpinned by tests but is published in the panel UI. Pre-launch there are no real subscribers, which is exactly why this is the cheap moment to change it.
  • Validate at boundaries — a token arriving in a URL is untrusted external input and belongs in the same Zod boundary as the rest of the query string.
  • Consistent error semantics — the routes currently return plain-text 404/500, not JSON. Whatever the auth failure returns must be deliberate and documented, and must not become an empty 200 (the existing :62-70 comment is the precedent to honour).

supabase/.claude/skills/supabase-postgres-best-practices:

  • security-rls-performance — wrap auth functions in a subselect: using ((select auth.uid()) = user_id), not using (auth.uid() = user_id). Called once and cached rather than per row; the skill claims 100x on large tables. Any feed_tokens operator policy must use the subselect form.
  • security-privileges — least privilege; “never grant ALL”. This directly contradicts the repo’s own house style, which is GRANT ALL ON TABLE … TO authenticated (the form_attachments exemplar, :80-81). For a credential table the skill should win over the house style, and the spec should say so explicitly rather than let the exemplar be copied.
  • schema-foreign-key-indexes — an FK needs a covering index; the exemplar already does this (form_attachments:68-69).

  • DR-134 — the governing ruling. Mechanism (b), URL-borne. Its “Consequences” section already names the token table shape (workspace_id, token hash, label, created/revoked/last_used) and the display-once issue flow. Two of its cited figures are corrected above (rate-limit call sites 10→33; SHA-256 hashing not net-new).
  • DR-038 / S452 + reference/entity-glossary.md:15,19 — workspace is the legacy tier. Source of the naming tension above.
  • DR-123 — a task directive, goal text, AC or code comment is evidence of intent at that time, never of correctness. Applied twice here: the original “intentionally unauthenticated” framing, and rate-limit.ts:6’s “single-user system”.
  • DR-104 / DR-106 — ratified docs outrank code; not every docs-site doc is ratified. Applied to specs/intelligence-workspaces/ above.
  • DR-032 — the api-views surface; the mechanism behind OQ-2.
  • id-347 — the anon-lockdown lesson whose anti-pattern is still live in 20 policies (I1).
  • id-225 (“Rate limiting on non-AI routes”, backlog, spec-needed) — the adjacent task that owns the limiter’s real fix. DR-134 rejected rate limiting as this task’s mechanism; it does not follow that the limiter is fine.
  • Memory recall surfaced no prior attempt at feed auth beyond what the id-166 journal already records. The E2E feed assertions were found via recall and are recorded above as consumers.

All claims below are documentation-derived against current primary sources, with the publication date of each read recorded. No real feed reader was tested and no deployed instance was probed — see the coverage note at the end.

DR-134’s CDN reasoning is REFUTED. The decision survives on better grounds.

Section titled “DR-134’s CDN reasoning is REFUTED. The decision survives on better grounds.”

DR-134’s Context states: “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.”

Three surrounding facts are upheld:

  1. Vercel does CDN-cache App Router route-handler responses on s-maxage, by default, with no config (/docs/caching/cdn-cache, last updated 2026-04-07). (Distinct from Next.js’s own Full Route Cache, which does NOT cache GET route handlers by default since Next 15 — do not let the two be conflated.)
  2. A URL-borne credential IS in the cache key. Cache key = method + request URL + host + deployment URL + scheme, and “query strings are ignored for static files — the only qualifier, and it excludes static files only. “Cache keys are not configurable.”
  3. s-maxage is stripped before the response reaches the client, so a reader sees Cache-Control: public, max-age=900.

But the bypass claim itself is false for the standard Authorization header. Vercel’s “Cacheable response criteria” states verbatim: “Request doesn’t contain Authorization header.” A request bearing Authorization is never stored in the CDN, so the authorised caller’s response never enters the cache and there is nothing for an anonymous caller to be served. The anonymous caller’s own 401 is also uncacheable (not in the permitted status list). The scenario DR-134 describes cannot occur via Authorization: Bearer on Vercel.

The claim holds only for a custom header (X-Feed-Token), which is neither in the default cache key nor in the exclusion list — and even that is fixable in one line, which directly contradicts “a bypass no handler-side code could catch”. Vercel documents Vary as a first-class cache-key mechanism: “combines the cache key with the values of any request headers specified in the Vary header”. Vary: X-Feed-Token puts a custom-header credential in the cache key at the same cardinality as a URL token.

What the CDN facts actually say: Authorization: Bearer would silently disable CDN caching entirely on these routes — every 15-minute poll from every subscriber invokes the function. That is a cost and latency argument, not a security one.

The carrier decision is still right. The reason is Q1 below, not the CDN. See DR-INTENT-1.

Feed-reader carrier compatibility — the header half is SETTLED

Section titled “Feed-reader carrier compatibility — the header half is SETTLED”

Two capabilities kept deliberately apart. HTTP Basic is not the same capability as a custom Authorization header — you cannot put an opaque token in it without abusing the password field.

ReaderURL w/ query intactCustom AuthorizationHTTP Basic
FeedlyYes (feedId is literally feed/<url>)NoNo
InoreaderYesNoYes — Basic + Digest, Pro only
NetNewsWireYesNoNo (#502 open since 2018; #2106 user:pass@host → “Feed not found”)
MinifluxYesNoYes, per-feed
FreshRSSYesNoYes, per-feed
ThunderbirdYesNoYes, but re-prompts every startup
Outlook (classic)YesNoNo“This behavior is by design” (KB 917125)
SharePoint OnlineYesNoNo — the RSS Viewer web part exists only in classic SharePoint; modern SPO has none
Hosted embeds (FeedWind, Elfsight, RSS.app)YesNoNo — vendor-side fetch

Sources: Miniflux client/model.go Feed struct on main (fields Username, Password, Cookie, UserAgent, ProxyURLno header field); FreshRSS app/views/feed/add.phtml:70-89 (http_user/http_pass only; issue #1627 for custom headers closed without the capability); MS KB 917125 (upd. 2025-09-11); MS Learn Q&A moderator reply 2024-09-24; Inoreader blog 2016-02-23; NetNewsWire issues #502/#2106.

Verdict: no mainstream feed reader or intranet embed widget documents the ability to send a custom Authorization header. The best on offer is HTTP Basic, absent from Feedly, NetNewsWire, and both likely enterprise consumers. A header-borne bearer token makes the feature unusable for most of the market. This is the evidence that fixes the carrier.

Capability-URL leakage — the concept’s origin and its catalogue

Section titled “Capability-URL leakage — the concept’s origin and its catalogue”

The proper carrier is the W3C TAG, Good Practices for Capability URLs, First Public Working Draft, 18 February 2014. Note its own status line — “not expected to become a Recommendation” — so cite it as a good-practice catalogue, not a standard. Its leakage list is exactly this problem: Referer, browser history, application and web-server logs, third-party scripts in a page reached via the URL, sharing and URL shorteners, and search indexing. Its recommendations: unguessable identifiers, https only, should expire, must be revocable with multiple issuable per resource.

Applied here:

  • The Referer vector is weaker than it looks for the feed itself — a feed is fetched by a reader, not rendered as a page. But it is live for rss-feed-panel.tsx, which renders the token URL inside an app page where any third-party script can read it. That is the W3C third-party-script vector, in scope, and it constrains the display-once UI.
  • Vercel runtime logs capture the token either way. /docs/logs/runtime (2026-08-03) log-detail fields include Request Path and Search Params, full-text searchable, visible to anyone with the Runtime Logs permission and shareable by URL. Retention 1 day (Pro) to 30 days. Path vs query buys nothing — both are logged. A disclosure to make, not a blocker.
  • public, max-age=900 reaches the reader, so any intermediary shared proxy stores the tokenised URL for 15 minutes (RFC 9111 §4: primary cache key is method + target URI).
  • Every reader stores the feed URL in plaintext in its own database, by construction. Inoreader says the quiet part out loud about Basic credentials: “Basic HTTP authentication cannot use encrypted passwords, so we need to store your password in our database.”
  • OPML export carries the token. OPML’s xmlUrl attribute is the feed address, so any export writes the tokenised URL verbatim, and OPML files get emailed and committed. Mechanism certain from the format; per-reader behaviour unverified — no export was performed.

URL length is a non-issue: Vercel’s CDN limit is 14 KB.

Three CDN constraints that must reach TECH.md

Section titled “Three CDN constraints that must reach TECH.md”
  1. Revocation is ineffective for up to s-maxage. A revoked token’s response sits in the CDN and is served to anyone replaying the URL; the cache key knows nothing about the DB. Without a purge, revocation is up-to-900-second theatre — and revocation is the entire reason option (b) was chosen. Fix: emit Vercel-Cache-Tag: feed-token-<id> and purge on revoke. Use dangerouslyDeleteByTag, NOT invalidateByTag — Vercel: “When you invalidate a cache tag… the next request serves the stale content instantly while revalidation happens in the background”, i.e. invalidate serves the still-authorised-looking body one more time. Limits: 256 chars/tag, 128 tags/response.
  2. last_used_at systematically under-reports. With s-maxage=900 and a 15-minute poll, most polls are CDN hits and never invoke the handler, so the column records only cache misses. Label the UI field “approximate — feeds are edge-cached”.
  3. Write amplification is a smaller problem than assumed. 96 requests/token/day is nothing; the spec should not over-engineer for it. GitLab’s precedent (a 24-hour throttle on PAT last_used_at, later moved toward 1 minute) exists, and hour-granularity via a predicate is the cheap shape — UPDATE … WHERE id = $1 AND (last_used_at IS NULL OR last_used_at < now() - interval '1 hour') — skipping the write in the engine with no read-then-write race. But OQ-6 is answered: coarsen if convenient, not because volume demands it.

Format — prefixed random token, GitHub-style, without the checksum. GitHub Engineering (2021-04-05, upd. 2023-05-10): type prefix + _ + random + CRC32 checksum, underscore chosen because “it will reliably select the whole token when you double click on it”. The checksum’s documented purpose is offline secret scanning — value that accrues only if you join a scanning partner programme or tokens leak into public repos. A feed token’s real exposure surface is OPML files, reader databases, widget vendor configs and CDN logs, none of which are scanned. The prefix alone buys the operationally useful part. Concrete suggestion: cfeed_ + 32 random bytes base64url (256 bits).

Storage — plain SHA-256, and NIST is the primary citation. SP 800-63B (Jun 2017, upd. 2020-03-02) draws the line at 112 bits:

  • §5.1.2.2: “Look-up secrets having at least 112 bits of entropy SHALL be hashed with an approved one-way function”; below 112 bits they “SHALL be salted and hashed using a suitable one-way key derivation function”.
  • §5.1.1.2: memorized secrets SHALL use a KDF regardless of entropy.

A 256-bit random token is a high-entropy look-up secret, so a plain unsalted SHA-256 is the standard-compliant choice. Two corrections to how this is commonly phrased, both of which would mislead a TECH.md author:

  1. Only one reason is load-bearing. “The slow-hash rationale does not apply” (entropy) is the justification. “It enables lookup by digest” is a consequence. Stating them as co-reasons invites someone later to reason “we need fast lookup, therefore fast hash” on a secret that is low-entropy. The hash choice is justified by ≥112 bits alone.
  2. “Constant-time lookup” conflates two things. An indexed equality probe is fast algorithmically; a Postgres B-tree comparison is not constant-time in the cryptographic sense and never will be. The accurate statement: digest lookup removes the need for a cryptographic constant-time compare, because the attacker supplies a candidate, you hash it, you probe the index — a timing oracle leaks only about a value the attacker already controls. (If you instead selected rows by workspace_id and compared hashes in app code, that comparison would need crypto.timingSafeEqual.)

Lookup shape. Digest column, UNIQUE + B-tree index. Not a hash index — Postgres hash indexes “do not allow uniqueness checking”. A separate prefix column is not needed for lookup (that shape is a bcrypt workaround); it is needed for display. Better Auth’s schema is the model to copy: alongside the hashed key, a prefix and a start (“Starting characters of the API key”), purely so the revoke list can render cfeed_…a9F2. A label, not an index, not an auth path.

Display-once — what people get wrong (the researcher’s judgement, flagged as uncited): rendering the full token URL in a list view rather than only at creation — the likely failure mode here, because the issue surface is being bolted onto the existing rss-feed-panel.tsx; putting plaintext into a server-rendered page other scripts can read; no copy affordance, so users screenshot the token; no label at issue time, so the revoke list is six identical rows and nobody dares revoke any — which defeats the decision; and allowing re-issue without revoking, so orphans accumulate.

Hash in the application, never pgcrypto. digest() puts the plaintext token into the SQL statement, where it reaches log_statement, pg_stat_statements and Supabase’s Postgres logs — the exact leak being avoided. Send only the digest. node:crypto is already available; no extension, no extra migration surface.

Grant nothing. For a token table the strongest first layer is no GRANT to anon or authenticated — failing at 42501 before RLS is consulted, the same posture already measured for anon on feed_articles — with RLS enabled and the table reached only via createServiceClient(). This is the concrete form of the I2 warning above, and it also settles the tension between the repo’s GRANT ALL … TO authenticated house style and security-privileges.

Build vs adopt: BUILD, and it is not close

Section titled “Build vs adopt: BUILD, and it is not close”
  • Better Auth apiKey plugin — the only serious TS/Postgres candidate and genuinely well-shaped. Disqualified twice: it is a plugin to Better Auth, and this repo’s auth is @supabase/ssr + @supabase/supabase-js (package.json:93-94) with no Better Auth anywhere — adopting it means adopting a second auth system to get one token table; and its default carrier is a header, so you would override the fetch path with customAPIKeyGetter, replacing the part you adopted it for.
  • Unkey — real and open-source, but API-gateway-shaped; self-hosting is a Go service plus MySQL plus Redis. Two datastores for one row per token.
  • Supabase-native — nothing exists. Auth issues JWTs for users; RLS gates rows. Neither is a long-lived, URL-borne, revocable, per-subscriber credential. (The 2025 sb_publishable_/sb_secret_ move is a prefixing precedent, not a consumable product.)

Honest scope of building it: one migration (one table + unique index), a ~30-line generate/hash helper on node:crypto, one verify helper, two route call sites, an issue endpoint, a revoke endpoint, and the panel change. This is not the cocoindex situation — cocoindex replaced an incremental-recompute engine, a hard general problem with real prior art. A hashed-token table is not that.


  1. Revocation does not work without a CDN purge, and revocation is the whole point. DR-134 chose option (b) specifically to get revocation and attribution. With s-maxage=900 and no cache-tag purge, a revoked token keeps serving for up to 15 minutes — and the purge API has a footgun (invalidate serves the stale authorised body once more; only dangerouslyDeleteByTag does what revocation means). This is a first-class requirement, not an optimisation, and nothing in DR-134 or the task file mentions it.

  2. The issue/revoke surface is where the credential leaks, not the feed. The feed is fetched by a reader; the token URL is rendered in an app page by rss-feed-panel.tsx, where the W3C third-party-script vector is live. The security effort belongs on the panel and the operator path — which is also where RLS actually bites, since createServiceClient() bypasses it on the feed path.

  3. “Intranet embed” may not be a reachable consumer at all. No CORS anywhere plus global X-Frame-Options: DENY and frame-ancestors 'none' rules out browser-side widgets; SharePoint Online has no RSS web part; hosted widgets (FeedWind, Elfsight, RSS.app) fetch vendor-side and would be handed the token as stored account configuration. PRODUCT.md should state which consumers are actually supported rather than inheriting “feed readers and intranet embeds” from the task text.

  4. last_used_at will be wrong and someone will trust it. Most polls are CDN hits, so the column records only cache misses. An operator deciding “this token is unused, revoke it” from an under-reporting field is a foreseeable mistake with a destructive outcome.

  5. The two feeds differ sharply in sensitivity and the earlier session proposed splitting them. The /filtered/ feed exposes relevance_reasoning on rejected articles — the AI explaining the client’s evaluation criteria. The product’s own UI already distinguishes them with a competitor warning. DR-134 ruled one mechanism for both; whether both feeds need the same posture is not re-litigation of the mechanism and remains open (OQ-7).


Most likely: a revoked token keeps serving, and nobody notices. The failure is silent by construction — revocation succeeds in the DB, the UI shows the token gone, and the CDN keeps serving the cached body to anyone replaying the URL. It would only surface if someone deliberately replayed a revoked URL within the window. What makes it robust: cache-tag purge with dangerouslyDeleteByTag on revoke, plus a test that revokes and then replays the URL and asserts 401 — an assertion that must run against a deployed instance, because it cannot fail locally where there is no CDN.

Second most likely: the token never survives the trip. Two independent mechanisms could mangle it, and neither has been tested: parseSearchParams splits comma-separated values into arrays (lib/validation.ts:117-120), and no reader documents whether it preserves, normalises, reorders or truncates query parameters, or drops them on redirect. What makes it robust: exclude commas from the token alphabet and carry the token in the path, plus a real subscribe-and-observe test against at least Feedly, Inoreader and Miniflux before the carrier is fixed. DR-134 already requires this and it is still not done.

Third: the fragile dependency is the s-maxage=900 header itself. Three separate behaviours now hang off it — cacheability, revocation latency, and last_used_at fidelity. It is a two-token literal in two route files with no comment explaining what depends on it. Someone tuning feed freshness will change it and silently change the revocation window. What makes it robust: a named constant with a comment naming the three dependants, and a test that pins it.


DR-intents (returned, not written in-branch)

Section titled “DR-intents (returned, not written in-branch)”

DR-INTENT-1 — amend DR-134’s stated rationale, not its ruling. DR-134’s Context asserts a header credential is bypassable via the shared CDN and that no handler-side code could catch it. Both halves are false for Authorization: Bearer (Vercel excludes such requests from the cache entirely) and the second is false even for a custom header (Vary fixes it in one line). The ruling is unchanged and the carrier choice is strengthened — it now rests on measured feed-reader capability rather than an inferred cache property. The Consequences should also gain the cache-purge requirement, and the two corrected figures (rate-limit call sites 10→33; SHA-256 not net-new). Per the register’s write routing, this is returned as an intent for the owner to apply on main.



OQ-1 — the carrier. The header half is SETTLED; the URL half is still UNDECIDABLE.

DR-134 carried this as one question. It is two, and they resolved differently.

  • SETTLED — the Authorization-header half. No mainstream feed reader or intranet embed widget documents the ability to send a custom Authorization header (matrix above, nine readers, primary sources). HTTP Basic exists in four of them but is a different capability. This is now the evidence that fixes the carrier, replacing the refuted CDN argument.

  • STILL UNDECIDABLE, carried verbatim:

    “Do mainstream feed readers and intranet embed widgets preserve a long-lived credential in the URL path/query intact — i.e. does any of them normalise, reorder, strip or truncate query parameters, or drop the credential when following a redirect?”

    No reader documents its URL handling in either direction. The only signals are indirect and are not documentation of preservation: Feedly’s feedId is the literal URL prefixed feed/, and Miniflux is reported to rewrite the stored feed URL on redirect (#1387). Settling it needs a subscribe-and-observe test against real readers, which was not run. DR-134 requires it settled by test before the carrier is fixed. Do not downgrade this to “probably fine”.

Note I4 narrows the scope: with no CORS and global frame-denial, a browser-side intranet widget is impossible regardless, so only native readers and server-side embeds are in scope.

OQ-1b — NEW, and it may be the sharpest exposure in the design. Carried verbatim:

“Does a feed added to Feedly by one user — stored as feed/<tokenised-url> in Feedly’s shared cloud, which Feedly fetches ‘only once for all users’ — become discoverable to other Feedly users through Feedly’s search or discovery surfaces?”

Feedly’s search docs describe searching “all the team sources and feeds defined in the account” and say nothing about whether user-added feeds enter a global searchable index; the fetcher page confirms the multi-tenant storage but not the indexing question. Not documented either way. If yes, a tokenised URL added to Feedly is a public capability URL — which would materially change the design.

OQ-2 — where does feed_tokens live on the Data API surface? Two coherent options; the spec must pick one before the migration is written:

  1. Omit from SURFACE_TABLES — the credential table never reaches PostgREST (safer default), and the issue/revoke routes read it via a service client with a .schema('public') override. lib/supabase/schema.ts:25-28 records that the S4 audit found zero existing uses of that override — this would be the first, so it needs a deliberate call, not a silent one.
  2. Add to SURFACE_TABLES with authenticated + service_role grants and an RLS predicate carrying the isolation, so the cookie client reads it normally.

OQ-3 — table and column naming. Confirm workspace_id scoping and the table name against the glossary’s legacy-tier ruling. See the naming tension above.

OQ-4 — should proxy.ts’s isApiRoute blanket exemption be narrowed? This is open question (c) from the original task Notes. Recommend explicitly deferring it to a separate task. It touches all 147 route files, every one of which already self-gates in-handler. A correction to the task’s framing: PUBLIC_ROUTES (lib/routes.ts:11-15) is a page-route allowlist — ['/login', '/auth/callback', '/oauth/consent'] plus /.well-known — so CLAUDE.md’s “new public endpoints MUST be added to publicRoutesdoes not apply to /api/* at all; isApiRoute already covers them.

Adjacent, not blocking: proxy.ts:101-103 calls supabase.auth.getUser() — a network round-trip — on every matched request including these RSS routes, whose result is then unused for /api/*. Feed-reader polling pays that cost today.

OQ-5 — does the token survive parseSearchParams? lib/validation.ts:117-120 splits comma-separated values into arrays. A token containing a comma would be mangled. Either the generator must exclude commas from the alphabet, or the token must be read off the path rather than the query. Mechanism identified, not tested — settle it by test in TECH.

OQ-6 — write amplification on last_used_at. ANSWERED, and the premise was wrong. 96 requests/token/day is nothing; coarsening is standard practice (GitLab shipped a 24-hour PAT throttle) but volume does not demand it here. The real problem is correctness, not volume — most polls are CDN hits, so the column under-reports systematically. Coarsen if convenient; label the field approximate.

OQ-7 — NEW. Do both feeds need the same posture? The /filtered/ feed is the sharper exposure — relevance_reasoning on rejected articles is the AI explaining the client’s evaluation criteria — and the product’s own UI already distinguishes the two with a competitor warning. The pre-DR-134 research proposed a middle path (token for filtered, cheaper for passed). DR-134 ruled one mechanism; whether both feeds need identical treatment is not re-litigation of the mechanism and is unresolved.

OQ-8 — NEW, and it decides where the gate lives. Carried verbatim:

“Does Vercel Routing Middleware execute on a CDN cache HIT?”

Vercel’s log documentation lists “Routing Middleware Invocation” and “Vercel CDN Cache” as separate log resources, which hints middleware may not run on a hit. If it does not, no middleware-based gate can protect a cached response at all, and the cache-tag purge design becomes mandatory rather than belt-and-braces. Unverified. Resolve before TECH.md picks a gate location.

OQ-9 — NEW, cheap to close. Do the routes emit <atom:link rel="self">? If so the self-link must carry the token or be omitted, or the feed publishes its own credential. Not checked.


  • Platform prod and every client DB are unchecked. All SQL is Platform staging only. Policy and grant drift between projects is plausible and unmeasured.
  • No runtime execution. No dev server, no request issued to either route, no test run — test counts are read from describe/it blocks, not from a run. Nothing here is validated against a deployed instance.
  • Projection blind spots, stated with the results they qualify. The unauthenticated-route sweep is a grep for a fixed set of auth-helper identifiers across 147 route.ts files; it mis-scored the 3 hand-rolled cron checks and was only corrected by reading all 10 candidate files individually. It cannot see auth applied via an unnamed wrapper, a re-exported handler, a dynamically-resolved call, or a check inside an imported helper — the same blind-spot class as S547’s getattr(...) dispatcher loss. Treat the 147/10 split as read-verified, not as proof no other route gates itself unusually.
  • The s-maxage sweep covers explicit header literals and route-segment revalidate exports. It would not see a header set via a computed key, injected by an SDK wrapper at runtime, or configured in the Vercel dashboard rather than vercel.json.
  • GitNexus returned a confirmed FALSE ZERO on this ground — do not size this change from it. route_map and api_impact both report consumers: [] / riskLevel: LOW for both RSS routes, while rss-feed-panel.tsx:121-122 demonstrably constructs those exact URLs; the tool cannot see template-literal URL construction. Every consumer claim above comes from grep plus file reads.
  • Not evaluated: whether an anon RLS policy could substitute for a token (DR-134 notes it looks strictly worse); token rotation and expiry semantics; rate limiting for the issue/revoke routes; the RLS policy text for the new table beyond the grant posture.

Coverage limits specific to the external research

Section titled “Coverage limits specific to the external research”
  • Zero real feed readers were tested. The entire compatibility matrix is documentation-derived, and for the URL-preservation half the documentation is largely silent — which is why OQ-1’s second half survives as UNDECIDABLE rather than resolving. A PASS on the header half records what the docs say, not runtime behaviour.
  • No deployed instance was probed. The CDN findings are documentation-derived — the same class of evidence as the inference they challenge, just from current primary docs rather than an emitted header. The Authorization-excluded-from-cache rule should be confirmed by one curl against a preview deployment before TECH.md leans on it.
  • Readers not checked at all: Feedbin, NewsBlur, Reeder, Readwise Reader, Tiny Tiny RSS, the Windows Common Feed List, Slack’s RSS app, the Teams RSS connector.
  • Per-reader OPML export behaviour was derived from the format’s xmlUrl attribute, not verified by exporting from any reader.
  • No Supabase project was queried by the external pass — all Supabase guidance there is from documentation. (The DB findings earlier in this document are separately measured against staging.)

A second UNDECIDABLE, raised by this research and carried verbatim:

Does Vercel’s edge actually serve these two responses without invoking anything, given that proxy.ts’s matcher matches /api/… and the proxy performs a supabase.auth.getUser() network call on every matched request — i.e. does middleware execute ahead of the shared-cache lookup on this deployment, and if so is a header credential genuinely unreachable by the handler or merely unreachable by the route handler specifically?

Not executed; no deployed instance was probed and nothing was inferred. This does not disturb DR-134’s ruling — a URL-borne token is in the cache key and composes safely either way. It bears only on how the rejected header option is described.