Questify

3.8.0

Released

Features

  • A public Patch notes page at /patch-notes renders the released sections of CHANGELOG.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; the 1.x sections 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 webpack asset/source rule in next.config.mjs), parsed by apps/web/src/lib/patch-notes/parse-changelog.ts and rendered with react-markdown as 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 — and generateStaticParams() on the release page runs it during next build, so a changelog the page cannot render fails the build rather than rendering blank. Registered as a public route: canonical URL and robots metadata, 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.tsx reuses 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.ts in 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, what v4 pointed 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) and superfly/flyctl-actions/setup-flyctl (1.6). Before this, whoever controlled one of those tags controlled the step that ran it; setup-flyctl was on @master in 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-owned actions/* and github/codeql-action/* stay on tags. A local CodeQL actions security-and-quality run goes from 15 unpinned-tag findings on develop to none; the missing-workflow-permissions half of the issue was already closed by ci.yml's read-only default token (#723) (#728).

  • The PRs Only repository ruleset now targets main and develop as 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.sql merges 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, since user_owns_quest() and user_owns_task() ignore quests.deleted_at while user_can_access_quest() checks it — and where a to public policy overlapped a to authenticated one the result is split by role, anon keeping only the arms that can hold without a user (so a signed-out read of a public quest's tasks no longer calls user_owns_quest() per task). The admin for all policies 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 two auth.role() = 'service_role' policies on the stall tables are dropped: service_role bypasses RLS and never evaluated them, so the only session they could admit was a non-bypassing role with a forged claim. With no anon-evaluated policy calling them, user_owns_quest() and user_owns_task() lose their anon EXECUTE (two anon_security_definer findings gone). Policy names change where the rule changed shape; no row that any caller could read or write before is admitted or refused differently, which supabase/tests/permissive_policies.sql proves 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() (or auth.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.sql alters 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>_idx the 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/nextjs in apps/web (browser, Node and edge) and @sentry/node in apps/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_DSN for the web app (public by design, and inlined at build time — with it unset next.config.mjs skips withSentryConfig, so no client SDK or wrapped handler exists, and register() returns before importing anything) and SENTRY_DSN for the worker, so local, Docker, CI and preview runs send nothing. The web app initialises in src/instrumentation.ts (with experimental.instrumentationHook now always on, because next start re-reads next.config.mjs at run time, where the build-time DSN is usually absent) and src/instrumentation-client.ts, withSentryConfig wraps route handlers and Server Components, and the error.tsx boundaries report client-side render errors through reportBoundaryError(), which loads the SDK on demand and also reports a Server Component error's redacted copy tagged server_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 of main(), 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 deliberate beforeSend / beforeBreadcrumb scrubber (apps/web/src/lib/monitoring/scrub.ts, copied byte for byte to apps/worker/src/monitoring/scrub.ts): only user.id survives, 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 and sendDefaultPii are off. Both apps report as questify@<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 web Dockerfile, docker-compose.yml and turbo.json; the worker's SENTRY_DSN is in the dev TUI's MANAGED_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.json extends the root config and adds $TURBO_ROOT$/CHANGELOG.md to the build and test inputs of the web workspace, so a changelog change now changes both task hashes. Turbo's default inputs stop at the package boundary, and CI caches .turbo (and vercel.json builds through Turbo), so without it a release that promoted only the changelog and the version files could have restored the previous .next output — the new release missing from /patch-notes and the parser's build-time check never run — and a cached web#test could have hidden a malformed entry (PR #920 review) (#658).

  • apps/web/Dockerfile copies CHANGELOG.md into the build stage, because /patch-notes inlines it at build time and the standalone image never carried the repository root; next.config.mjs gains the ?raw webpack rule (asset/source) that makes import text from './file.md?raw' a string, typed by apps/web/src/types/raw-imports.d.ts, and Vite serves the same suffix natively under Vitest (#658).

  • New .github/dependabot.yml keeps the action pins current: weekly on Mondays it opens one pull request against develop (the default branch is main) 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 covers github-actions only; npm dependencies are unchanged (#728).

  • supabase/advisor-allowlist.txt's BACKLOG section is empty: the 34 multiple_permissive_policies_* entries and the two anon_security_definer_function_executable_* lines for user_owns_quest() / user_owns_task() are gone with their findings, so a second permissive policy for one command, or a new anon grant on either helper, fails the advisor step (#659).

  • supabase/advisor-allowlist.txt lost its 55 auth_rls_init_plan_* BACKLOG entries, so a new policy that compares a bare auth.uid() fails the advisor step rather than being waved through; the remaining backlog is the 34 multiple_permissive_policies entries (#659).

CI/CD

  • The E2E job in ci.yml runs on ubuntu-24.04 instead of ubuntu-latest. When ubuntu-latest moved to Ubuntu 26.04, Playwright 1.60 refused to install Chromium there, so the job failed before any test ran (#657).

  • release.yml passes SENTRY_AUTH_TOKEN (a production environment secret) and the SENTRY_ORG / SENTRY_PROJECT variables to vercel build, so a release with a Sentry DSN uploads its browser source maps under questify@<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 root package.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 starts next dev with NEXT_TELEMETRY_DISABLED=1. On a fresh runner next dev sends 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-notes and 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 in scripts/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 Postgres Key (…)=(…) / 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 by apps/worker/src/monitoring/scrub.test.ts for the worker's job context and PostgREST URLs. New scripts/monitoring-scrub-twins.test.mjs fails pnpm test:scripts if the two copies drift. sentry.test.ts and instrumentation.test.ts cover the DSN gate (nothing initialises unset or blank), the options and the boundary reporter; worker sentry.test.ts covers init, the investigation context and the no-DSN no-op; processor.test.ts asserts 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.ts asserts one report per failure streak; route-error.test.tsx, global-error.test.tsx and env.test.ts cover the rest (#657).

  • scripts/turbo-inputs.test.mjs fails pnpm test when apps/web/turbo.json stops extending the root config, drops $TURBO_ROOT$/CHANGELOG.md or $TURBO_DEFAULT$ from the build or test inputs, or changelog.ts stops importing the repository-root file; scripts/lighthouse-production.test.mjs says why CHANGELOG.md still does not start the Lighthouse audit now that it feeds a page (#658).

  • apps/web/src/lib/patch-notes/parse-changelog.test.ts parses the repository's real CHANGELOG.md through the page's parser and floor, so a pull request whose entry the page could not render fails pnpm test before next build gets to fail on it, and pins each malformed shape by line number against a fixture; changelog.test.ts checks the newest release matches the root package.json version and the oldest is the floor; release-notes.test.tsx covers 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.tsx and [version]/page.test.tsx pin the index split, the cadence line, valid heading order, the neighbour links, the 404, generateStaticParams(), dynamicParams = false and the metadata; page.render.test.tsx renders the index and every release page from the real file. site-footer.test.tsx, sitemap.test.ts and redirects.test.ts pin the footer link, the sitemap entry and the route staying public, and e2e/patch-notes.spec.ts loads 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.mjs fails pnpm test when a workflow references a third-party action by anything but a 40-character commit SHA, when a pin lacks its # vX.Y.Z comment, when one action is pinned to two different commits, or when .github/dependabot.yml stops covering github-actions or targeting develop — 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 into develop. All five checks pass on this branch; the two pin checks fail on develop's workflows. scripts/ci-jobs.test.mjs now expects the coverage comment action as a SHA with a v2.x comment (#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, with for all and to public expanded 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 forged service_role claim no longer reads the stall tables while the real service role still does, and that anon can no longer call user_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.sql restates splinter's two performance lints as pgTAP assertions — no policy in public evaluates auth.*() or current_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 of quests planning 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 bare auth.uid() beside a wrapped one is caught (splinter's like / not like pair 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() in apps/web/src/lib/quests/detail-page.ts owns 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 from env) and the clock as an argument; buildQuestMetadata() builds the <head> beside it. QuestDetailView in components/quests/quest-detail-view.tsx is the page's markup as a synchronous Server Component, and page.tsx is now only the glue that maps the loader's result to redirect() / 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 from page.test.ts). The new detail-page.test.ts covers 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.tsx covers 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.tsx adds the redirect, the 404 and generateMetadata. 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.md records what Sentry captures in each app, what the scrubber removes, how releases are named, the setup steps (two Sentry projects, NEXT_PUBLIC_SENTRY_DSN in Vercel Production only, the GitHub SENTRY_AUTH_TOKEN secret and variables, the worker's Fly secret) and how to verify each surface; docs/ops/deployments.md lists the new Vercel variable and Fly secret (#657).

  • docs/cicd.md records that CHANGELOG.md is a Turbo input of web and 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 the changelog-entry skill say that CHANGELOG.md is now the public /patch-notes page 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 into PATCH_NOTES_HEADINGS deliberately (#658).

  • docs/cicd.md gains "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 with git ls-remote, and what scripts/action-pins.test.mjs enforces. The comment above release.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 pass does not block a merge and nothing stops a direct push to main or develop. 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 from main before merging it), records the three dormant rulesets and what enforcing them would still lack, and keeps the old rules as a target. The target main rule drops "Require linear history", which would block every release since releases land on main as merge commits, and notes that with one Code Owner the approval rules need a bypass. The deploy diagram's required arrow, AGENTS.md, README.md, the pull request template, .github/CODEOWNERS and the comments in ci.yml, lighthouse.yml and scripts/ci-jobs.test.mjs that said branch protection requires ✅ CI pass were brought in line (#731).

  • docs/schema.md gains the 20261130120000 migration-order row and the permissive_policies.sql coverage entry; docs/ops/database-advisors.md records the backlog as empty and the merge pattern for a for all admin policy; two code comments and docs/notifications.md stop 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.md records auth_rls_initplan as cleared and the permissive-policy lint as what remains of the backlog (#659).

  • docs/testing-server-components.md records 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 a load*() in src/lib, a synchronous View and a thin page, with flags and the clock passed in; a page that only reads and renders may keep the render(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).