RESEARCH — ID-166 RSS feed auth
RESEARCH — ID-166 RSS feed auth
Section titled “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.
What exists today
Section titled “What exists today”The two routes
Section titled “The two routes”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()atrss/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-8andCache-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:
- Explicit emission, repo-wide (not just
app/):s-maxagereturns exactly 4 hits — the two routes (:103each) and their two tests. Every otherCache-Controlin the app is private or non-cacheable (no-cache,no-store,private, max-age=30). - Implicit CDN caching via route segment config, which a header grep cannot
see: no
revalidateexport anywhere inapp/. No route acquires an implicits-maxagefrom 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-Allowreturns zero hits acrossapp/,lib/,vercel.jsonandnext.config.ts. A browser-side intranet widget doing a cross-originfetch()would be blocked. Server-side embeds and native feed readers are unaffected. - Framing is globally denied.
vercel.jsonsetsX-Frame-Options: DENYand CSPframe-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.
The client surface
Section titled “The client surface”components/intelligence/rss-feed-panel.tsx (153 lines), rendered at exactly one
site (app/intelligence/[workspaceId]/page.tsx:103).
- The lines to replace:
:116-119derivesbaseUrlfromwindow.location.origin;:121buildspassedUrl;:122buildsfilteredUrl. All plain template literals — no server round-trip, nothing to hook. RssFeedPanelPropsis{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-150stating “No authentication is required, so treat the filtered feed URL as confidential.”
Consumers that break when auth lands
Section titled “Consumers that break when auth lands”Neither is recorded in the task file:
e2e/tests/intelligence-workflow.spec.ts:~160and:192—page.request.get()against both feeds assertingstatus === 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 fromdescribe/itblocks, 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”| Pattern | Call | Why |
|---|---|---|
lib/mcp/auth.ts:createMcpUserClient (:23-38) | Do not build on | Header-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 code | Copy: 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.ts | Do not wrap | shouldValidateResponseBody (: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 call | Feed routes: no — confirmed cookie-only (:89 → createClient() → 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 task | See Open Question OQ-4. |
components/settings/connected-apps-section.tsx + app/api/oauth/revoke/route.ts | Build on shape, not fetching | The nearest “list credentials, revoke one” surface. But it stores nothing — revoke/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.ts | Shape reference only; reject as credential model | The 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,argon2— zero hits acrosslib/andapp/. - 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')) andapp/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 (*_tokensLLM counters,source_documents.content_hash, the plaintext fence lease).
Migration and RLS conventions
Section titled “Migration and RLS conventions”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 MCPapply_migration/execute_sql. cat supabase/.temp/project-refbefore every push — the link drifts to prod silently.- Stamp against the REMOTE
schema_migrationsmax, non-round (both S481 collisions were round timestamps). Verify withto_regclass, neversupabase migration listparity. - Every policy MUST name its roles explicitly (
TO authenticated,TO service_role) — the id-347 lesson, andsupabase/CLAUDE.mdrecords that no CI gate covers it. See Issue I1. - RLS enable is belt-and-braces:
20260726120000_ensure_rls_event_trigger.sql:44-49auto-enables RLS on everyCREATE TABLEinpublic, 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;relforcerowsecurityfalse on all 10. - S541’s “five are
USING (true)forauthenticated” is exactly right —feed_articles,feed_prompts,feed_sources,pipeline_runs,si_processing_queue. - Extending it: the feed’s entire read path is unpredicated.
workspacesitself (missed by aworkspace_idsweep — it keys onid) hasworkspaces_select TO authenticated USING (true), andapplication_types_select_allisTO authenticated, service_role USING (true). All four tables the RSS routes touch areUSING (true)forauthenticated. - 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 targetingPUBLIC—polrolesis literally{0}, i.e. authored with noTOclause.supabase/CLAUDE.mdnames this exact failure as the real id-347 exposure. Their predicates are the weak parent-existence checkEXISTS (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. anongrants — challenged and extended, holds. S541 measuredpubliconly; the app’s Data API isschemas = ["api"], so the anon-reachable surface is really theapiviews. Measured both: zeroanongrants inpublicAND zero inapi, 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.
Rate limiting
Section titled “Rate limiting”lib/rate-limit.ts — DR-134’s characterisation holds; its figure does not.
- In-memory
Mapat: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:
:6justifies the design as “acceptable for a single-user system where auth gates all routes.” That is false today (multi-workspace, multi-role,user_roleswith admin/editor/viewer). Per DR-123 the comment is evidence of intent at authoring time, not of correctness.
Issues found
Section titled “Issues found”Each carries an explicit resolve-first vs build-on call.
| # | Issue | Call |
|---|---|---|
| I1 | 20 policies across the 5 facet tables target PUBLIC (no TO clause) — the live id-347 anti-pattern, with no CI gate covering it | Not 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. |
| I2 | The 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. |
| I3 | verifyCronAuth exists; 3 of 5 cron routes hand-roll it inline anyway | Build on the shape, not the code. Direct evidence that verifyFeedToken needs route-level 401 tests, not convention. |
| I4 | No 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. |
| I5 | SURFACE_TABLES (scripts/generate-api-views.ts:121) is an explicit allowlist, but every client routes .from('x') → api.x via DB_OPTION | Resolve-first — a decision, not code. See OQ-2. |
| I6 | rate-limit.ts:6’s “single-user system” premise is false | Do 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. |
| I7 | connected-apps-section.tsx uses raw fetch() against the TanStack-Query-exclusive rule | Build on shape, not fetching. New issue/revoke UI belongs in lib/query/. |
| I8 | defineRoute would be a no-op on application/rss+xml | Do 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.
A note on specs/intelligence-workspaces/
Section titled “A note on specs/intelligence-workspaces/”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.
Best practice
Section titled “Best practice”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-70comment 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), notusing (auth.uid() = user_id). Called once and cached rather than per row; the skill claims 100x on large tables. Anyfeed_tokensoperator policy must use the subselect form.security-privileges— least privilege; “never grant ALL”. This directly contradicts the repo’s own house style, which isGRANT ALL ON TABLE … TO authenticated(theform_attachmentsexemplar,: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).
Prior decisions & context
Section titled “Prior decisions & context”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, andrate-limit.ts:6’s “single-user system”.DR-104/DR-106— ratified docs outrank code; not every docs-site doc is ratified. Applied tospecs/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.
External findings
Section titled “External findings”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:
- 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.) - 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.”
s-maxageis stripped before the response reaches the client, so a reader seesCache-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.
| Reader | URL w/ query intact | Custom Authorization | HTTP Basic |
|---|---|---|---|
| Feedly | Yes (feedId is literally feed/<url>) | No | No |
| Inoreader | Yes | No | Yes — Basic + Digest, Pro only |
| NetNewsWire | Yes | No | No (#502 open since 2018; #2106 user:pass@host → “Feed not found”) |
| Miniflux | Yes | No | Yes, per-feed |
| FreshRSS | Yes | No | Yes, per-feed |
| Thunderbird | Yes | No | Yes, but re-prompts every startup |
| Outlook (classic) | Yes | No | No — “This behavior is by design” (KB 917125) |
| SharePoint Online | Yes | No | No — the RSS Viewer web part exists only in classic SharePoint; modern SPO has none |
| Hosted embeds (FeedWind, Elfsight, RSS.app) | Yes | No | No — vendor-side fetch |
Sources: Miniflux client/model.go Feed struct on main (fields Username,
Password, Cookie, UserAgent, ProxyURL — no 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
Referervector 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 forrss-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 includeRequest PathandSearch 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=900reaches 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
xmlUrlattribute 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”- 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: emitVercel-Cache-Tag: feed-token-<id>and purge on revoke. UsedangerouslyDeleteByTag, NOTinvalidateByTag— 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. last_used_atsystematically under-reports. Withs-maxage=900and 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”.- 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.
Token design
Section titled “Token design”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:
- 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.
- “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_idand compared hashes in app code, that comparison would needcrypto.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
apiKeyplugin — 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 withcustomAPIKeyGetter, 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.
What are we not thinking about?
Section titled “What are we not thinking about?”-
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=900and no cache-tag purge, a revoked token keeps serving for up to 15 minutes — and the purge API has a footgun (invalidateserves the stale authorised body once more; onlydangerouslyDeleteByTagdoes what revocation means). This is a first-class requirement, not an optimisation, and nothing in DR-134 or the task file mentions it. -
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, sincecreateServiceClient()bypasses it on the feed path. -
“Intranet embed” may not be a reachable consumer at all. No CORS anywhere plus global
X-Frame-Options: DENYandframe-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. -
last_used_atwill 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. -
The two feeds differ sharply in sensitivity and the earlier session proposed splitting them. The
/filtered/feed exposesrelevance_reasoningon 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).
If this breaks in three months, why?
Section titled “If this breaks in three months, why?”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.
Open questions
Section titled “Open questions”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 customAuthorizationheader (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
feedIdis the literal URL prefixedfeed/, 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:
- 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-28records 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. - Add to
SURFACE_TABLESwithauthenticated+service_rolegrants 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 publicRoutes” does 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.
What this research did NOT cover
Section titled “What this research did NOT cover”- 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/itblocks, 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.tsfiles; 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’sgetattr(...)dispatcher loss. Treat the 147/10 split as read-verified, not as proof no other route gates itself unusually. - The
s-maxagesweep covers explicit header literals and route-segmentrevalidateexports. 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 thanvercel.json. - GitNexus returned a confirmed FALSE ZERO on this ground — do not size this
change from it.
route_mapandapi_impactboth reportconsumers: []/riskLevel: LOWfor both RSS routes, whilerss-feed-panel.tsx:121-122demonstrably 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
anonRLS 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
PASSon 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 onecurlagainst 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
xmlUrlattribute, 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 asupabase.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.