Conversation
Subscription enforcement was half-implemented: the subscriptions table
and the backend require_subscription gate existed, but the Supabase
custom access-token hook that projects the subscribed_tools claim into
the JWT was never built, so every module-gated route 403'd for every
user.
- Add migration public.custom_access_token_hook(event jsonb): aggregates
module_id from public.subscriptions where status in ('active',
'trialing') into a subscribed_tools array claim (empty array when no
rows), merged into the event claims. Grants execute to
supabase_auth_admin, revokes from authenticated/anon/public, and adds
a SELECT policy scoped to supabase_auth_admin (the table RLS is
auth.uid()-keyed and never matches the hook's own session).
- Enable [auth.hook.custom_access_token] in stacker supabase config.toml.
- Correct docs/subscriptions.md to match the implemented hook and note
the hosted evermore-auth enablement step (tracked as #157).
7 tasks
Follow-ups from an architecture + security re-review of the custom access-token hook (approach confirmed correct and kept): - Document that subscribed_tools is evaluated only at token-issue time, so a canceled/past_due subscriber retains access up to the access-token TTL. State the invariant that jwt_expiry must stay <= 3600s, since the revocation window equals the TTL. - Reconcile the evermore-auth vs stacker naming: one Supabase auth project, hosted as evermore-auth, whose schema is sourced from apps/stacker/supabase/ migrations (local project_id "stacker"). Same project, not two databases.
4 tasks
Member
Author
Re-evaluation of the approach (operator-requested)Concern raised: Supabase Auth Hooks are beta, so a more mature standard may exist. Ran independent architecture (Opus) and security reviews, both fetching current Supabase docs (2026-07-13). Verdict: keep the custom access-token hook. It is the mature, Supabase-recommended standard, and the beta premise is outdated.
Folded into this PR (commit afd5cd2, docs-only, gates unaffected):
Filed as follow-up: #291 (deploy/CI smoke check asserting the claim is projected, so a silently-disabled hosted hook is caught loudly instead of locking out every user). Detection, not a DB fallback (a fallback would convert fail-closed to fail-open). Pairs with #157. |
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #288
Summary
Subscription enforcement was half-implemented: the
public.subscriptionstable and the backendrequire_subscriptiongate existed, but the Supabase custom access-token hook that projects thesubscribed_toolsclaim into the JWT was never built. With the claim never present,require_subscription(...)403'd every user on every module-gated route.This adds the token-side half:
apps/stacker/supabase/migrations/20260713000000_custom_access_token_hook.sql: definespublic.custom_access_token_hook(event jsonb) returns jsonb, which aggregatesmodule_idfrompublic.subscriptionsfor rows withstatus in ('active','trialing')into asubscribed_toolsarray claim (empty array when no rows, never null, never an error), merges it into the event claims, and returns the event. Grantsexecutetosupabase_auth_admin, revokes it fromauthenticated/anon/public, adds a column-scopedselect (user_id, module_id, status)grant, and an RLS policy scoped tosupabase_auth_admin(the table's existingauth.uid()-keyed policy never matches the hook's own session).apps/stacker/supabase/config.toml: enables[auth.hook.custom_access_token]pointing at the function so local dev issues tokens with the claim.docs/subscriptions.md: corrected to describe the implemented hook and to document the hosted enablement step (tracked as Configure production SMTP (Cloudflare Email Service) for Supabase auth emails #157).Design note: the function is
security invoker(notsecurity definer), following Supabase's documented hook grant/RLS pattern.security defineron a migration-created function runs aspostgres(bypassrls) and would silently sidestep RLS onpublic.subscriptions; the explicit role-scoped grant + policy is the safer path.Pipeline phases
coalesceon the claims read), 1 accepted as inherent design risk (claim-based entitlement lags token TTL, bounded by the 1h access-token lifetime).Test results
Both configured gate commands were run in a clean worktree and exit 0:
(cd services/retriever && uv sync && uv run python -m pytest tests/ --ignore=tests/integration) && (cd apps/stacker && npm ci && npm run build)— exit 0 (371 passed, 10 deselected; SvelteKit build ✓).ruff check+ruff format --check+mypy --strict; petdataruff+mypy+bandit; stackernpm run check— exit 0 (all ruff checks passed, mypy clean on both services, bandit no issues, svelte-check 0 errors / 0 warnings).The SQL/config/docs diff is not exercised by these gates (no SQL harness in-repo; petdata Postgres tests are CI-delegated); the migration was additionally validated by executing it end-to-end against a disposable Postgres instance (column grant blocks
select *, coalesce fixes the missing-claims case).Eval scores
No evals configured in this repo.
Operator handoff (out of code scope)
Two acceptance criteria require production access + a deploy and cannot be completed in a worktree:
evermore-authproject: Auth -> Hooks -> Custom Access Token, pointing at thecustom_access_token_hookPostgres function. This is manual dashboard config today; config-as-code tracking is Configure production SMTP (Cloudflare Email Service) for Supabase auth emails #157.retrieversubscription row seeded inevermore-auth, a fresh login should carrysubscribed_tools: ["retriever"]and Retriever chat should return 200 instead of 403.