Summary
Deployed Retriever chat returns 403 {"detail":{"error":"subscription_required","module":"retriever"}} for a fully authenticated user with a valid token. The subscription enforcement chain is only half-implemented: the subscriptions table and the backend gate exist, but the Supabase custom access-token hook that projects the subscribed_tools claim into the JWT was never built. As a result the claim is never present on any token, and require_subscription(...) rejects every user on every gated route.
This surfaced while diagnosing a deploy-time auth failure. The prior 401 Invalid or expired token (hosted evermore-auth was on HS256; the Retriever's SUPABASE_URL pointed at the wrong project) has been resolved by rotating evermore-auth to ES256 and aligning both SUPABASE_URL (Retriever Worker) and PUBLIC_SUPABASE_URL (Stacker) to evermore-auth. Auth now validates cleanly; this 403 is the remaining blocker.
Evidence
docs/subscriptions.md states a Supabase auth hook "projects the user's active module ids into a subscribed_tools claim on the JWT itself (per-request, at token-issue time)."
- No such hook exists: no SQL function defining
subscribed_tools or a custom_access_token hook in any migration or SQL file (apps/stacker/supabase/migrations/ contains only 20260702000000_subscriptions.sql; seed.sql has no hook).
apps/stacker/supabase/config.toml:279-282 — the [auth.hook.custom_access_token] block is the commented-out stock template, never enabled.
packages/auth/src/evermore_auth/dependencies.py:84 — AuthUser defaults the claim to empty when absent (tuple(payload.get("subscribed_tools", []))), so a token with no claim yields subscribed_tools = () and require_subscription("retriever") raises 403 (dependencies.py:98-102).
Root cause
The token-side half of the subscription model (the access-token hook) was documented but never implemented, so subscribed_tools is never on any token. Every module-gated route 403s regardless of the subscriptions table contents.
Where the fix lives
Everything belongs in the evermore-auth project (the subscriptions table references auth.users, and a custom access-token hook runs at token-issue time in the auth project's Postgres). The apps/stacker/supabase/ migrations are the source for that project.
Acceptance Criteria
Notes / test path
- For testing without a real Stripe purchase, insert a row directly in
evermore-auth: insert into public.subscriptions (user_id, module_id, status) values ('<uuid>', 'retriever', 'active'); then re-login (the claim is baked in at token issue).
- Quick triage on any failing token: decode it and check for
subscribed_tools. Absent ⇒ hook not live; present-but-empty ⇒ hook live, missing subscription row.
Summary
Deployed Retriever chat returns
403 {"detail":{"error":"subscription_required","module":"retriever"}}for a fully authenticated user with a valid token. The subscription enforcement chain is only half-implemented: thesubscriptionstable and the backend gate exist, but the Supabase custom access-token hook that projects thesubscribed_toolsclaim into the JWT was never built. As a result the claim is never present on any token, andrequire_subscription(...)rejects every user on every gated route.This surfaced while diagnosing a deploy-time auth failure. The prior
401 Invalid or expired token(hostedevermore-authwas on HS256; the Retriever'sSUPABASE_URLpointed at the wrong project) has been resolved by rotatingevermore-authto ES256 and aligning bothSUPABASE_URL(Retriever Worker) andPUBLIC_SUPABASE_URL(Stacker) toevermore-auth. Auth now validates cleanly; this 403 is the remaining blocker.Evidence
docs/subscriptions.mdstates a Supabase auth hook "projects the user's active module ids into asubscribed_toolsclaim on the JWT itself (per-request, at token-issue time)."subscribed_toolsor acustom_access_tokenhook in any migration or SQL file (apps/stacker/supabase/migrations/contains only20260702000000_subscriptions.sql;seed.sqlhas no hook).apps/stacker/supabase/config.toml:279-282— the[auth.hook.custom_access_token]block is the commented-out stock template, never enabled.packages/auth/src/evermore_auth/dependencies.py:84—AuthUserdefaults the claim to empty when absent (tuple(payload.get("subscribed_tools", []))), so a token with no claim yieldssubscribed_tools = ()andrequire_subscription("retriever")raises 403 (dependencies.py:98-102).Root cause
The token-side half of the subscription model (the access-token hook) was documented but never implemented, so
subscribed_toolsis never on any token. Every module-gated route 403s regardless of thesubscriptionstable contents.Where the fix lives
Everything belongs in the
evermore-authproject (thesubscriptionstable referencesauth.users, and a custom access-token hook runs at token-issue time in the auth project's Postgres). Theapps/stacker/supabase/migrations are the source for that project.Acceptance Criteria
apps/stacker/supabase/migrations/that, given the hook event'suser_id, readspublic.subscriptionsfor rows withstatus in ('active','trialing')and adds asubscribed_toolsarray claim (module ids) to the token claims. Absent/empty subscriptions yield an empty array, not an error.supabase_auth_admin(and execute is revoked fromauthenticated/anon/publicas appropriate), per Supabase custom-access-token-hook requirements.apps/stacker/supabase/config.tomlenables[auth.hook.custom_access_token]pointing at the function (uncomment + set thepg-functions://uri) so local dev issues tokens with the claim.evermore-authproject (Auth → Hooks), and this hosted step is documented (ties into the config-as-code work, Configure production SMTP (Cloudflare Email Service) for Supabase auth emails #157).docs/subscriptions.mdis corrected/expanded so the hook it describes matches the implemented function (it currently describes the hook as if it exists).retrieversubscription row carriessubscribed_tools: ["retriever"]in the token, and Retriever chat returns 200 instead of 403.Notes / test path
evermore-auth:insert into public.subscriptions (user_id, module_id, status) values ('<uuid>', 'retriever', 'active');then re-login (the claim is baked in at token issue).subscribed_tools. Absent ⇒ hook not live; present-but-empty ⇒ hook live, missing subscription row.