3.8.0
Released
Features
A public Patch notes page at
/patch-notesrenders the released sections ofCHANGELOG.md, newest first, and is linked from the site footer beside Privacy and Terms (#658). The six newest releases are shown in full — Features, Design, Fixes and Security as the entries were written, with Database, Tooling, CI/CD, Tests and Documentation folded under a closed "Technical changes" disclosure (a<details>, so the toggle needs no client code) — and every older release is a row in an "Earlier releases" list (version, date, "4 features · 2 fixes") linking to its own page at/patch-notes/[version], which also links to its newer and older neighbours. The page covers every release from Questify 2.0.0 (2026-05-26) up; the1.xsections describe the retired Nuxt application and are not parsed.## [Unreleased]is never shown. The Markdown is inlined at build time (import … from '../CHANGELOG.md?raw', a webpackasset/sourcerule innext.config.mjs), parsed byapps/web/src/lib/patch-notes/parse-changelog.tsand rendered withreact-markdownas HTML in a Server Component, so the page reads no database and ships no client bundle. The parser is strict — a subsection heading outside its table, a released section with no subsections, a subsection with no entries, a malformed release heading or date, or prose that is not an entry throws — andgenerateStaticParams()on the release page runs it duringnext build, so a changelog the page cannot render fails the build rather than rendering blank. Registered as a public route: canonical URL androbotsmetadata, the sitemap (the index weekly at 0.5, and every release page yearly at 0.3, since a released section is never edited), the two Lighthouse audit lists and the footer; the middleware already leaves it public. A page-level toggle, a search, and an RSS feed were left for later (#658).
Fixes
A page that crashed with no error boundary of its own — a public, auth, onboarding or invitation page, or the root layout — now shows the branded "Something went wrong" panel with Try again and a link home, instead of Next.js's unbranded "Application error: a client-side exception has occurred" screen.
apps/web/src/app/global-error.tsxreuses the(app)and admin boundaries'RouteErrorPanel, so the raw error message is still never shown (#657).
Security
A failed AI investigation no longer sends the name of the attachment or the reference link it could not read to Sentry. The worker's failure messages name them (
Could not read <file name>: …,Cannot send <file name> to a model: …,Cannot read reference link <url>: …), and the shared scrubber (scrub.tsin both apps) now replaces the name or link with[Filtered]up to the last:in the message, so a name that itself contains:or a line break is still masked and the report keeps the reason that follows it. Found by review of the 3.8.0 release before any DSN was set, so nothing was sent (#657).Every third-party GitHub Action is now pinned to a full commit SHA, with its release as a trailing comment, instead of a movable tag or branch — 15 references across eight workflows:
pnpm/action-setup(v4.3.0, whatv4pointed at),davelosert/vitest-coverage-report-action(v2.13.1),marocchino/sticky-pull-request-comment(v2.9.4),treosh/lighthouse-ci-action(12.6.2),supabase/setup-cli(v1.7.3) andsuperfly/flyctl-actions/setup-flyctl(1.6). Before this, whoever controlled one of those tags controlled the step that ran it;setup-flyctlwas on@masterin the production release job, beside the deploy secrets. Each pin is the commit its tag already resolved to, so no step runs different code; newer majors (pnpm v6, sticky-comment v3, setup-cli v3) are left for Dependabot to propose one at a time. GitHub-ownedactions/*andgithub/codeql-action/*stay on tags. A local CodeQLactionssecurity-and-qualityrun goes from 15unpinned-tagfindings ondevelopto none; themissing-workflow-permissionshalf of the issue was already closed byci.yml's read-only default token (#723) (#728).The PRs Only repository ruleset now targets
mainanddevelopas two patterns. It had one malformed pattern,refs/heads/"main", "develop", that matched no branch, so it would have protected nothing even once rulesets are enforced. This is a repository setting changed in GitHub, not a file in this diff; the rulesets stay unenforced while the repository is private on GitHub Free (#731).
Technical changes· 27 entries for database, tooling, CI, tests and docs
Database
Every table now has one permissive policy per command and role (#659).
20261130120000_merge_permissive_policies.sqlmerges the 34 overlapping sets the performance advisor reported (multiple_permissive_policies), where Postgres evaluated every policy of the set for every row: each merged predicate is the literal OR of the originals — nothing is simplified away, sinceuser_owns_quest()anduser_owns_task()ignorequests.deleted_atwhileuser_can_access_quest()checks it — and where ato publicpolicy overlapped ato authenticatedone the result is split by role,anonkeeping only the arms that can hold without a user (so a signed-out read of a public quest's tasks no longer callsuser_owns_quest()per task). The adminfor allpolicies on the catalogs (packs, personas, Jams, Challenges, roles, privileges, role assignments) become one policy each for INSERT, UPDATE and DELETE with the admin term folded into the merged SELECT, and the twoauth.role() = 'service_role'policies on the stall tables are dropped:service_rolebypasses RLS and never evaluated them, so the only session they could admit was a non-bypassing role with a forged claim. With noanon-evaluated policy calling them,user_owns_quest()anduser_owns_task()lose theiranonEXECUTE (twoanon_security_definerfindings gone). Policy names change where the rule changed shape; no row that any caller could read or write before is admitted or refused differently, whichsupabase/tests/permissive_policies.sqlproves arm by arm and the unchanged rest of the suite confirms (#659).The 55 RLS policies that compared a column to a bare
auth.uid()(orauth.role()) now wrap the call as(select auth.uid()), and the 20 foreign keys without a covering index have one (#659).20261129120000_rls_initplan_fk_indexes.sqlalters each policy's USING / WITH CHECK in place — predicates, names, roles and commands are otherwise the live ones, so nothing about who can read or write what moves, which the full pgTAP suite passing unchanged confirms — because Postgres treated the bare call as per-row work (the function is STABLE, not IMMUTABLE), re-reading the JWT claim for every row a policy filtered; wrapped, it is one InitPlan per statement. The indexes are the<table>_<column>_idxthe rest of the schema uses, partial (where … is not null) on the nullable columns, and are what a delete of a user, task, quest or catalog row walks to enforce the constraint instead of scanning the child table. Production holds too few rows for either to show today; this is done before it does. The advisor's 25 "unused" indexes were left alone, and the overlapping permissive policies (0006) are a separate change (#659).
Tooling & Configuration
Production errors from the web app and the AI worker are now reported to Sentry —
@sentry/nextjsinapps/web(browser, Node and edge) and@sentry/nodeinapps/worker— so a web 500, a client-side React crash or a failed investigation is grouped, kept and alertable instead of living only in Vercel's or Fly's logs, or nowhere. It is off unless a DSN is set:NEXT_PUBLIC_SENTRY_DSNfor the web app (public by design, and inlined at build time — with it unsetnext.config.mjsskipswithSentryConfig, so no client SDK or wrapped handler exists, andregister()returns before importing anything) andSENTRY_DSNfor the worker, so local, Docker, CI and preview runs send nothing. The web app initialises insrc/instrumentation.ts(withexperimental.instrumentationHooknow always on, becausenext startre-readsnext.config.mjsat run time, where the build-time DSN is usually absent) andsrc/instrumentation-client.ts,withSentryConfigwraps route handlers and Server Components, and theerror.tsxboundaries report client-side render errors throughreportBoundaryError(), which loads the SDK on demand and also reports a Server Component error's redacted copy taggedserver_digest, because on Next 14 a throw from a nested async Server Component is not wrapped server-side and that copy is its only report. The worker reports a failed investigation before marking the row, tagged with its stage, failure reason and queued model and with the investigation id, task id and every model the fallback chain tried as context; a poll-loop outage once per streak; and a crash out ofmain(), flushed before exit — its Sentry settings are read on their own before the rest of the env, so an invalid env var is reported too. Every event goes through one deliberatebeforeSend/beforeBreadcrumbscrubber (apps/web/src/lib/monitoring/scrub.ts, copied byte for byte toapps/worker/src/monitoring/scrub.ts): onlyuser.idsurvives, request bodies, cookies, query strings and all but five headers are dropped, the value of any content or credential key (title,details,prompt,body,email,token, …) is replaced at any depth, and email addresses, tokens, URL query values and the row values Postgres quotes in constraint errors are masked in every string; tracing, replay andsendDefaultPiiare off. Both apps report asquestify@<root package.json version>. The variables are registered in.env.example, both env schemas (getSentryDsn()reads the web DSN on its own so monitoring never depends on the Supabase keys parsing), the webDockerfile,docker-compose.ymlandturbo.json; the worker'sSENTRY_DSNis in the dev TUI'sMANAGED_KEYS(empty by default), while the Playwright web server stays without a DSN. Server Actions and the worker's email, nudge, jam-copy and attachment loops are not reported yet (#657).New
apps/web/turbo.jsonextends the root config and adds$TURBO_ROOT$/CHANGELOG.mdto thebuildandtestinputs of thewebworkspace, so a changelog change now changes both task hashes. Turbo's default inputs stop at the package boundary, and CI caches.turbo(andvercel.jsonbuilds through Turbo), so without it a release that promoted only the changelog and the version files could have restored the previous.nextoutput — the new release missing from/patch-notesand the parser's build-time check never run — and a cachedweb#testcould have hidden a malformed entry (PR #920 review) (#658).apps/web/DockerfilecopiesCHANGELOG.mdinto the build stage, because/patch-notesinlines it at build time and the standalone image never carried the repository root;next.config.mjsgains the?rawwebpack rule (asset/source) that makesimport text from './file.md?raw'a string, typed byapps/web/src/types/raw-imports.d.ts, and Vite serves the same suffix natively under Vitest (#658).New
.github/dependabot.ymlkeeps the action pins current: weekly on Mondays it opens one pull request againstdevelop(the default branch ismain) with every minor and patch release of a GitHub Action, rewriting each SHA and its version comment together, and a pull request of its own for each major release. It coversgithub-actionsonly; npm dependencies are unchanged (#728).supabase/advisor-allowlist.txt's BACKLOG section is empty: the 34multiple_permissive_policies_*entries and the twoanon_security_definer_function_executable_*lines foruser_owns_quest()/user_owns_task()are gone with their findings, so a second permissive policy for one command, or a newanongrant on either helper, fails the advisor step (#659).supabase/advisor-allowlist.txtlost its 55auth_rls_init_plan_*BACKLOG entries, so a new policy that compares a bareauth.uid()fails the advisor step rather than being waved through; the remaining backlog is the 34multiple_permissive_policiesentries (#659).
CI/CD
The E2E job in
ci.ymlruns onubuntu-24.04instead ofubuntu-latest. Whenubuntu-latestmoved to Ubuntu 26.04, Playwright 1.60 refused to install Chromium there, so the job failed before any test ran (#657).release.ymlpassesSENTRY_AUTH_TOKEN(aproductionenvironment secret) and theSENTRY_ORG/SENTRY_PROJECTvariables tovercel build, so a release with a Sentry DSN uploads its browser source maps underquestify@<version>and deletes them from the output before deploying; without the token the build still succeeds. The worker deploy adds--env SENTRY_RELEASE=questify@<version>from the rootpackage.json, so the worker reports under the same release name as the web app (#657).The Playwright web server (
apps/web/playwright.config.ts) now startsnext devwithNEXT_TELEMETRY_DISABLED=1. On a fresh runnernext devsends its first-run telemetry session event before printing "Ready", and when telemetry.nextjs.org did not answer that wait outlived the 120 s the server is given, so the E2E job failed with "Timed out waiting 120000ms from config.webServer" before a single spec ran — twice in a row on PR #920 with no change to the app under test, and reproducibly in a sandbox where that host is unreachable. The Docker image already opted out; CI now does too (#658)./patch-notesand one release page,/patch-notes/2.0.0(the oldest, so its URL never moves), are audited by both Lighthouse workflows (lighthouse.yml,lighthouse-production.yml) and pinned inscripts/lighthouse-production.test.mjs, so both public pages — the index and the separately rendered release page with its own heading, disclosure and neighbour links — are held to the same accessibility floor as the legal pages (#658).
Tests
The Sentry scrubber is pinned by
apps/web/src/lib/monitoring/scrub.test.ts— the user reduced to its id; the request body, cookies, query string and non-allow-listed headers dropped; content and credential keys replaced at any depth whatever their casing; emails, JWTs, bearer credentials, URL query values and PostgresKey (…)=(…)/Failing row contains (…)values masked in exception messages; invitation tokens and the/recap/[shareId]share UUID masked in paths while other UUIDs stay; stack-frame locals, UI breadcrumb selectors and console arguments dropped — and byapps/worker/src/monitoring/scrub.test.tsfor the worker's job context and PostgREST URLs. Newscripts/monitoring-scrub-twins.test.mjsfailspnpm test:scriptsif the two copies drift.sentry.test.tsandinstrumentation.test.tscover the DSN gate (nothing initialises unset or blank), the options and the boundary reporter; workersentry.test.tscovers init, the investigation context and the no-DSN no-op;processor.test.tsasserts a failed investigation is reported with its ids, stage, reason and attempted models and no prompt text, and a successful one is not;loop.test.tsasserts one report per failure streak;route-error.test.tsx,global-error.test.tsxandenv.test.tscover the rest (#657).scripts/turbo-inputs.test.mjsfailspnpm testwhenapps/web/turbo.jsonstops extending the root config, drops$TURBO_ROOT$/CHANGELOG.mdor$TURBO_DEFAULT$from thebuildortestinputs, orchangelog.tsstops importing the repository-root file;scripts/lighthouse-production.test.mjssays whyCHANGELOG.mdstill does not start the Lighthouse audit now that it feeds a page (#658).apps/web/src/lib/patch-notes/parse-changelog.test.tsparses the repository's realCHANGELOG.mdthrough the page's parser and floor, so a pull request whose entry the page could not render failspnpm testbeforenext buildgets to fail on it, and pins each malformed shape by line number against a fixture;changelog.test.tschecks the newest release matches the rootpackage.jsonversion and the oldest is the floor;release-notes.test.tsxcovers the article, the Markdown rendering (emphasis and code kept, raw HTML and images dropped), the closed technical disclosure and its count, a release with nothing user-facing, the heading levels on the index versus a release page, and the archive rows;page.test.tsxand[version]/page.test.tsxpin the index split, the cadence line, valid heading order, the neighbour links, the 404,generateStaticParams(),dynamicParams = falseand the metadata;page.render.test.tsxrenders the index and every release page from the real file.site-footer.test.tsx,sitemap.test.tsandredirects.test.tspin the footer link, the sitemap entry and the route staying public, ande2e/patch-notes.spec.tsloads the page signed out, opens the disclosure, follows the footer link to a release page and expects a 404 for a version never released (#658).New
scripts/action-pins.test.mjsfailspnpm testwhen a workflow references a third-party action by anything but a 40-character commit SHA, when a pin lacks its# vX.Y.Zcomment, when one action is pinned to two different commits, or when.github/dependabot.ymlstops coveringgithub-actionsor targetingdevelop— so an unpinned action is caught in its own pull request rather than in the summary of a CodeQL run, which no longer runs on pull requests intodevelop. All five checks pass on this branch; the two pin checks fail ondevelop's workflows.scripts/ci-jobs.test.mjsnow expects the coverage comment action as a SHA with av2.xcomment (#728).supabase/tests/permissive_policies.sql(111 assertions) pins part 3 of #659: no table has more than one permissive policy for one command and role (splinter 0006, withfor allandto publicexpanded the way the lint does), and then each merged policy arm by arm — owner, party member, stranger, Admin, Support and anon against public, private, deleted and foreign quests, tasks, investigations, party seats, achievements, feedback, profiles, personas, packs, Challenges, Jams, role assignments, stall rows and recaps — including that the owner arms still reach a deleted quest's rows where the party arm does not, that a forgedservice_roleclaim no longer reads the stall tables while the real service role still does, and thatanoncan no longer calluser_owns_quest()/user_owns_task()yet still reads a public quest's tasks. Its 106 behavioural assertions pass on the schema before the merge as well; only the invariant and the forged-claim assertion fail there.security_advisors.sql's pinned anon-callable set shrinks by the two helpers (#659).supabase/tests/performance_advisors.sqlrestates splinter's two performance lints as pgTAP assertions — no policy inpublicevaluatesauth.*()orcurrent_setting()per row (0003), and every foreign key has a covering index (0001, which is INFO-level and so never failed the CI advisor guard) — pins the 20 indexes by name, and shows a signed-in read ofquestsplanning the owner term as an InitPlan parameter rather than a per-row claim read. The 0003 check is stricter than splinter's: it strips every wrapped(select …)occurrence and flags any call left, so a bareauth.uid()beside a wrapped one is caught (splinter'slike/not likepair lets it through), and the file proves that on a probe table with one policy per shape. Each of its 25 assertions fails on the schema before the migration (#659).The Quest page (
/quests/[id]) is now split so its logic is testable without mounting an async Server Component (#683).loadQuestDetailPage()inapps/web/src/lib/quests/detail-page.tsowns every read and access decision (sign-in for a signed-out visitor, 404 for anyone else on a missing or private quest, which reads run for the owner, a member or a visitor, and what each optional read degrades to), taking its feature flags as a parameter (questDetailFlagsFromEnv()builds them fromenv) and the clock as an argument;buildQuestMetadata()builds the<head>beside it.QuestDetailViewincomponents/quests/quest-detail-view.tsxis the page's markup as a synchronous Server Component, andpage.tsxis now only the glue that maps the loader's result toredirect()/notFound(). Nothing a user sees changed: the 31 existing whole-page render tests pass unchanged through the split, and the source-order layout test now reads the View's file (quest-detail-view.layout.test.ts, moved frompage.test.ts). The newdetail-page.test.tscovers each viewer's result, the reads a public visitor or party member must not trigger, invitation expiry against the given clock, null-versus-empty degradation for attendance, the streak and the recurring template, earlier-cycle pins, the tier-narrowed personas and the metadata;quest-detail-view.test.tsxcovers the visitor, member and owner views, the breadcrumb, the owner toolbar's upgrade link, the party panel's roster lock, attendance confirmation around the scheduled start, the streak, the Details card's model and theme, and the chronicle;page.render.test.tsxadds the redirect, the 404 andgenerateMetadata. The loader, the View and the page are each fully covered by line, up from the page's 72% (#683).
Documentation
New
docs/ops/error-monitoring.mdrecords what Sentry captures in each app, what the scrubber removes, how releases are named, the setup steps (two Sentry projects,NEXT_PUBLIC_SENTRY_DSNin Vercel Production only, the GitHubSENTRY_AUTH_TOKENsecret and variables, the worker's Fly secret) and how to verify each surface;docs/ops/deployments.mdlists the new Vercel variable and Fly secret (#657).docs/cicd.mdrecords thatCHANGELOG.mdis a Turbo input ofweband why, and no longer says a changelog-only pull request cannot change a byte of the pages Lighthouse measures — it says why the audit still skips one (#658).AGENTS.md→ Commit, PR, and Changelog Guidelines and thechangelog-entryskill say thatCHANGELOG.mdis now the public/patch-notespage and what its parser rejects — an unknown###heading, an empty subsection, prose that is not an entry — so an entry is written knowing which audience sees it and a new heading goes intoPATCH_NOTES_HEADINGSdeliberately (#658).docs/cicd.mdgains "Third-party actions are pinned to commits": why a tag is not enough, which actions are exempt, how Dependabot's weekly pull request should be reviewed, how to pin a new action by hand withgit ls-remote, and whatscripts/action-pins.test.mjsenforces. The comment aboverelease.yml's flyctl step no longer claims superfly publishes no version tags (#728).docs/cicd.md→ "Branch protection" no longer describes rules as if they were in force. No branch rule is enforced on this repository: it is private, the organization is on GitHub Free, and GitHub enforces neither classic protection nor rulesets there below GitHub Team, so a red✅ CI passdoes not block a merge and nothing stops a direct push tomainordevelop. The section now says so, lists the conventions that hold the line instead (pull requests only, merge on a green✅ CI pass, update a release pull request frommainbefore merging it), records the three dormant rulesets and what enforcing them would still lack, and keeps the old rules as a target. The targetmainrule drops "Require linear history", which would block every release since releases land onmainas merge commits, and notes that with one Code Owner the approval rules need a bypass. The deploy diagram'srequiredarrow,AGENTS.md,README.md, the pull request template,.github/CODEOWNERSand the comments inci.yml,lighthouse.ymlandscripts/ci-jobs.test.mjsthat said branch protection requires✅ CI passwere brought in line (#731).docs/schema.mdgains the20261130120000migration-order row and thepermissive_policies.sqlcoverage entry;docs/ops/database-advisors.mdrecords the backlog as empty and the merge pattern for afor alladmin policy; two code comments anddocs/notifications.mdstop naming policies that no longer exist (#659).docs/schema.md's Indexes section now says a new FK column gets its covering index in the same migration and names the pgTAP file that fails without one;docs/ops/database-advisors.mdrecordsauth_rls_initplanas cleared and the permissive-policy lint as what remains of the backlog (#659).docs/testing-server-components.mdrecords how to test an async Server Component page (#683): a page whose reads decide access, run per viewer or flag, or degrade on failure is split into aload*()insrc/lib, a synchronous View and a thin page, with flags and the clock passed in; a page that only reads and renders may keep therender(await Page(props))pattern the existing page tests use. It covers what each of the three tests asserts and where to pin layout order, with/quests/[id]as the worked example.AGENTS.md's Testing Guidelines point to it (#683).