2.25.0
Released
Features
The signup confirmation email is now Questify's own rather than Supabase's default.
supabase/templates/confirmation.htmlis a branded HTML email in the same style as the notification emails — the Questify wordmark, an indigo "Confirm my email" button on{{ .ConfirmationURL }}, the raw link as a fallback and a footer naming the address and site — with the subject "Confirm your Questify account", wired insupabase/config.tomlunder[auth.email.template.confirmation]so the local stack sends it to Mailpit. The link still returns through/auth/callbackwith the signup'snext, and the template does not interpolate the person's typed name. The hosted project does not readconfig.toml: the runbook step pastes the subject and body into the dashboard's Email Templates by hand. (#670)A public quest now has a shelf life. A quest shared by an ordinary user and then left alone for 30 days — no quest edit, no task written, no investigation — goes private again on the next daily run, so
/quests/publicstops accumulating abandoned quests beside the curated Questify Team ones. Nothing else about the quest changes: its status, tasks and party seats are untouched, it is still there under My Quests, and the owner can re-share it from the quest page, which restarts the 30 days. Admin-owned and Questify Team quests stay public indefinitely. The owner is not emailed about it; that was left as a follow-up. (#693)
Fixes
The admin section no longer scrolls sideways on a phone. Below
mdits navigation was a fixed strip of elevenwhitespace-nowraplinks inside anoverflow-x-autobar, so the row ran off the right edge with no hint that it scrolled and Feedback and Audit Log were only reachable by dragging the bar.AdminLayoutnow renders a sticky top bar (AdminMobileBar) with a menu button that opens a vertical drawer of every destination plus a "Back to app" link on the bar itself, and a desktop side bar (AdminSideNav) built on the sameNavLinkListas the signed-in shell, so the admin links now carryaria-currentfor the current section too. The item list moved tolib/navigation/admin-items.ts(ADMIN_NAV_ITEMS,ADMIN_EXIT_ITEM) and the RSC-boundary guard covers it. The bar is sticky rather than fixed, so<main>drops its hard-coded top padding. (#662)
Security
An anonymous caller could grant any account the Pro tier.
set_user_plan_tier(_user_id, _tier)guarded itself withauth.uid() is not null and not has_privilege('ai_plans.write'), which a request carrying only the anon key passes because itsauth.uid()is null, and Supabase's default grant made the function callable atPOST /rest/v1/rpc/set_user_plan_tier— reproduced against a database built from every migration.record_free_tier_action_for(_user_id, _kind)had the same shape and let anyone burn a free account's daily AI quota. Both are closed twice: EXECUTE is revoked fromanon(and fromauthenticatedon the free-tier recorder), and the bodies now fail closed —set_user_plan_tier()requires aservice_roleJWT orai_plans.write, andrecord_free_tier_action_for()refuses an anon JWT outright and a signed-in caller naming anyone but themselves — so a regressed grant cannot reopen either. Signed-in users were already gated correctly. (#695)
Technical changes· 17 entries for database, tooling, CI, tests and docs
Database
20260930120000_pin_function_search_path.sqlsetssearch_path = publicon the 22 functions inpublicthat had none — the one-line SQL helpers behind AI credits, free-tier limits, quest-status and task-origin checks, and theset_updated_at,prevent_quest_owner_changeandupdate_user_location_consent_updated_attriggers — matching the convention the other ~250 functions already follow, so the advisor'sfunction_search_path_mutablelint (0011) reports nothing. None sits in an index, default or constraint; the only side effect is that these SQL functions are no longer inlined. Proven bysupabase/tests/security_advisors.sql. (#695)20260930130000_tighten_always_true_policies.sqlreplaces the two policies whoseWITH CHECKwas the constanttrue(0024). "Guild owners can update their guilds." now checks the outgoing row: itsowner_idmust be the caller or an accepted member of that guild, so a stranger can never become owner even if the transfer trigger were dropped; the trigger keeps its23514when RLS is bypassed, and a stranger transfer through RLS now fails with42501before the trigger sees it (guilds.sqlupdated accordingly). "Anyone can submit a beta signup." still letsanoninsert, but only a row whose email looks like an email (≤ 254 chars) with each survey answer ≤ 2,000 chars andsource≤ 100; the API rate limit remains the volume guard. (#695)20260930140000_lock_down_definer_execute.sqlrevokes EXECUTE frompublic,anonandauthenticatedon every trigger function inpublic(a loop overpg_proc, so none is missed), revokesanon(and thePUBLICgrant six of them still had) on the 24 helpers that need a signed-in caller — the RLS predicateshas_privilege,has_role,is_admin,user_can_access_quest,user_can_manage_quest_party,user_is_quest_party_member, and the RPCs whose result depends onauth.uid()— and makesrecord_free_tier_action_for()service-role only, as its comment always said. TwelveSECURITY DEFINERfunctions keep theiranongrant because a public page or an anon-evaluated policy needs them (the invitation previews, the unsubscribe-by-token setters,get_quest_with_tasks,ensure_quest_jam_cycles,get_public_quest_owner_profiles, theget_quest_jam_*readers, anduser_owns_quest/user_owns_task, which PUBLIC-scoped policies ontasksandtask_investigationscall); they now hold that grant directly rather than throughPUBLIC. Default privileges are left alone — the advisor step already fails a PR that adds an exposed definer. (#695)20260930150000_drop_pg_graphql.sqldrops thepg_graphqlextension andgraphql_publicleaves[api].schemasinsupabase/config.toml. Nothing in the web app or the worker calls/graphql/v1, and while the extension is installed every tableanonorauthenticatedcanSELECTis discoverable — names, columns, relationships — through GraphQL introspection even with RLS on, which the advisor reports per table and role (0026/0027: 79 findings on this schema, and the reason the advisor step from #696 had failed on every run since it landed — those two lints only fire where pg_graphql is installed, which the seeding sandbox was not). The hosted project's exposed-schema list lives in the dashboard (Settings → API) and needsgraphql_publicremoved by hand;security_advisors.sqlasserts the extension is absent. (#695)20261001120000_auto_revert_inactive_public_quests.sqladdsquests.last_activity_at(NOT NULL, backfilled from the quest's own dates, its tasks'updated_atand its investigations'created_at, and added to the quest column allowlist20260914120000_due_date_privacy.sqlintroduced — a column left off it is not only unreadable but makesselect *fail, and when a quest was last worked on is not private the way a deadline is;due_atremains the only quest column clients may not read), the six triggers that keep it current — a quest created or edited, a task inserted, updated or deleted, an investigation created; a deletion is the one kind of task activity that leaves no row to read a date from, so it stampsnow(), and without it an owner who tidied up a shared quest could watch the sweep unshare it the same day,revert_inactive_public_quests(_inactivity_days integer default 30)and the dailyrevert-inactive-public-quests-dailypg_cron job at 09:45 UTC. The function unshares every public quest idle beyond the window and returns the rows it changed; it writesis_publicand nothing else — not evenupdated_at, whichset_updated_at()would otherwise stamp, sending every quest the sweep hides to the top of its owner's list, sincelistQuests()orders by it — exempts quests owned by an Admin or SuperAdmin and thepublic_quest_catalogsystem user, skips soft-deleted rows, is serialised on an advisory lock, rejects a window below one day, and is revoked fromanonandauthenticated— with_inactivity_days => 1it would otherwise empty the browse page for anyone holding the anon key. The column is bounded at both ends: it only moves forward, so a back-dated task import cannot rewind it, and never pastnow(), becausetasks_set_updated_atfires only on UPDATE and an INSERT through PostgREST keeps whateverupdated_atthe client sent — one task dated 2030 would otherwise hold a quest on the browse page until then, and an investigation'screated_atis the same.quest_last_activity_from_history(uuid)— which seeded the column and is the repair if it is ever doubted — clamps the same way, because every operand it reads is client-writable and a row posted from the future before this migration would seed the column there; it is a function rather than an expression inside the backfill UPDATE so that the clamp can be asserted. Sharing a quest counts as activity and unsharing does not, which is what lets the job leave the rows it has just made private with an honest date and makes re-sharing restart the clock; and a client writing the column itself is ignored on insert and on update, becausequestsis granted UPDATE at table level and there is no column privilege to revoke without re-granting the other thirty.quests.updated_atis not part of the ongoing definition —set_updated_at()writestimezone('utc', now())into atimestamptz, which is not comparable tonow()off UTC — so the trigger notices a quest edit directly; the backfill still reads it, clamped withleast(…, now()), as the only record of past edits. The window is the function's default argument and the cron command passes none. (#693)
CI/CD
The Supabase workflow now runs the dashboard's Security and Performance advisors against the local stack on every pull request that touches
supabase/, and fails on any WARN or ERROR finding that is not allowlisted.scripts/db-advisors.sh(alsopnpm supabase:advisors) fetches splinter at a pinned release tag, runs it withpsqlin one transaction withpgrst.db_schemasset to the schemassupabase/config.tomlexposes, keeps the WARN/ERROR rows and matches each key againstsupabase/advisor-allowlist.txt— exact keys and#comments, every entry standing for exactly one finding, so a second overload of a listed function fails rather than hiding behind its line. The one key the script rewrites ismultiple_permissive_policies: splinter's names a role the hosted project has and the local stack does not, so the script drops the role and keys on the policy set instead, and a policy added to an already-listed table is a new finding. The workflow's path filter includes the script, so a pin bump or matcher change runs against the local stack. Before this,supabase db lintwas the only database check and it is plpgsql_check, not the advisor lints, so every migration could add a mutablesearch_pathor an anon-callableSECURITY DEFINERfunction and the finding surfaced in the dashboard afterdb push. The allowlist is seeded from the 2026-09-19 advisor run — 304 entries, five of them intentional and commented, the rest a backlog the script reports as stale once each fix lands — so the guard goes green today and only stops the backlog growing; it does not shrink it. (#696)
Tests
supabase/tests/security_advisors.sql(37 assertions) pins the security-advisor state: every function inpublichas asearch_path, no trigger function is executable byanonorauthenticated, noSECURITY DEFINERfunction is reachable through aPUBLICgrant, and the setanonmay execute is exactly the twelve intended functions; then, as each role, thatanoncannot callset_user_plan_tier(), that the body refuses an anon JWT even when EXECUTE is granted and refuses a signed-in caller recording someone else's free-tier action, that the service role keeps both, thatanonstill reads a public quest's tasks, loads it throughget_quest_with_tasks()and mints Quest Jam cycles, that a guild owner cannot hand the guild to a non-member but can to an accepted one (and the trigger still rejects a non-member when RLS is bypassed), and that a beta signup with a malformed email or oversized text is refused while a well-formed one is not.rls.sqlgains six regression pins for the same grants and policies;guilds.sql's non-member transfer now expects the policy's42501. Each of the three migrations was removed in turn and its assertions failed. (#695)scripts/db-advisors.test.mjspins the advisor guard: the workflow step runs afterdb lintand before the stack stops with the same skip guard as its neighbours,pnpm supabase:advisorspoints at an executable script,SPLINTER_REFis a release tag rather than a branch, the workflow'spushandpull_requestpath filters include the script, the script's schema list equals[api].schemasand its default port equals[db].portinsupabase/config.toml, and every allowlist entry is an exact key naming one lint with no wildcard. (#696)scripts/dev-tui/env-flags.test.mjspins the branded confirmation template: the[auth.email.template.confirmation]section, its subject, acontent_paththat resolves from the repository root, and the{{ .ConfirmationURL }}placeholder inside the file. (#670)scripts/release-tags.test.mjsremoves its temporary repositories with retries. A bare remote a test has just pushed to can still have a detachedgit gc --autowriting under it when the test ends, which made one CI run fail its cleanup hook withENOTEMPTYon a change that never touched the script. (#692)supabase/tests/public_quest_auto_revert.sql(42 assertions) runs the job against seven quests that differ only in owner and idleness: the idle one is unshared and reported with its owner and idle date, the ten-day-old one, the one whose task was written today, the Admin-owned one, the Questify Team one and the soft-deleted one are all left alone, a second run returns nothing and rewrites no row (proven byctid, sincenow()does not move inside a transaction), a window of zero or null raises22023, re-sharing restarts the clock and survives the next run, and an owner writinglast_activity_atthrough PostgREST is ignored. The trigger half asserts what counts as activity — a quest edit, a task added, worked on or deleted, an investigation — and what does not: hiding a quest by hand, a task imported with a back-datedupdated_at(set on the INSERT, becausetasks_set_updated_atoverwrites it on every UPDATE), a task or investigation posted with a future timestamp, and a quest created with a futurelast_activity_at. Seven more cover the later review rounds:quest_last_activity_from_history()clamps a future-dated task tonow()rather than discarding it and is not a client RPC, the sweep leavesupdated_atalone, a hand edit still stamps it, and an owner deleting a task moves the clock.supabase/tests/public_quest_auto_revert_schedule.sql(15 assertions) pins the job after migrations alone — command, schedule, active, registered once — plus the function grants, that the trigger functions are not client RPCs, that the window is the function's default, that the column is NOT NULL, and that it is readable byanonandauthenticatedwhiledue_atstill is not. Twelve mutations of the migration were each caught by a different assertion. (#693)
Documentation
docs/schema.mdlists the three #695 migrations, states the EXECUTE model (who may run trigger functions, which twelve functionsanonmay execute and why, how the target-user helpers guard themselves) under Helper functions, and describes the beta-signupWITH CHECK;docs/ops/database-advisors.mdrecords that the backlog shrank from 304 to 184 entries, what the INTENTIONAL section now holds, that the 0011 fix isset search_path = publicrather than'', and the dashboard step for leaked-password protection (Authentication → Providers → Email → "Prevent use of leaked passwords"), which no migration can set.supabase/advisor-allowlist.txtdrops the 120 keys those migrations cleared, moves the eight anon-callable functions public pages need into INTENTIONAL with a reason each, and groups the 83 signed-in RPCs (0029) as the accepted API surface. (#695)docs/ops/database-advisors.mdexplains what the advisor step runs, how to read a failure and the migration line that clears each of the four common lints, how to allowlist an intentional finding with a comment, how the backlog section ratchets down, and how to bump the splinter pin. Theadd-migrationskill addspnpm supabase:advisorsto its fresh-database step and verification checklist, anddocs/cicd.md's workflow table names the step. (#696)docs/notifications.mdgains a "Supabase Auth templates" section describing the confirmation template, how the local stack picks it up and why production needs the dashboard step;docs/ops/deployments.mdadds that step to the Supabase checklist. (#670)Added a tool-agnostic skill set under
.agents/skills/for the routine procedures every coding agent kept rediscovering from prose:precommit-gate,changelog-entry,open-pr,add-migration,add-public-route,add-env-flagandcut-release, each aSKILL.mdwith a trigger description, the step-by-step procedure, the reason each step exists and a verification checklist.AGENTS.mdnames the directory as the canonical home for procedures and points at the relevant skill from each section;CLAUDE.mddrops its duplicated pre-commit section for a pointer;.github/copilot-instructions.md, which still described the retired Nuxt/Prisma layout, is now a short pointer toAGENTS.mdand the skills.docs/agent-skills.mdlists the skills, when to use each and how to add one. (#688)Retargeted repo copy from the completed Questify 2.0 rebuild to Questify 3.0: the README title and intro,
AGENTS.md, thepackage.jsondescription,.env.example,CODEOWNERS,docs/cicd.md,docs/schema.mdand the Supabase config andrls.sqlheader comments. The issue templates now link the 3.0 epic (#689) as the parent-epic example and contact link. The README's v2 roadmap keeps its historical references to epic #269 and issues #243–#268. (#690)docs/schema.mddocumentsquests.last_activity_at, the six triggers that maintain it andrevert_inactive_public_quests()under thequestssection, lists the migration and adds the RLS-coverage bullet;docs/ops/scheduled-jobs.mdadds the job to the running-jobs table and says what it decides, which column it reads and that it sends no email. (#693)