3.9.0
Released
Features
A quest owner can now turn on Collect evidence, and once a task on that quest is done anyone in the party can add a photo of it, or check in at the task's location. A model writes one short line describing what the photo shows; it describes, never judges, and never blocks or undoes the completion. A check-in records only whether you were there, not where you were. Evidence stays private to the quest's party, even on a public quest, and whoever added it (or the quest owner) can delete it, which also deletes the photo. Captions cost nothing on the free tier but count toward its daily AI limit; past that limit the photo is still saved, just without a caption. On a paid persona they are charged to whoever added the photo. Behind
NEXT_PUBLIC_FEATURE_TASK_EVIDENCE, off by default (#551).Links to Questify now unfurl in Slack, Discord, X and Reddit with a branded preview card, and a shared public quest or Quest Jam gets a card of its own naming it: a quest shows its title, task count and owner, a jam its title, theme, status and dates (#656). A private quest, or a jam not yet live, shows the generic card even when its owner shares the link, because every card is drawn as a signed-out visitor would see the page.
Questify now measures where people drop out of quest creation, onboarding, the AI Coach and challenges, so the beta can be steered by what visitors actually do (#652). Counting uses Plausible, which is cookieless and needs no consent banner. It sends nine funnel events:
quest_pack_selected,quest_create_started/quest_create_completed,onboarding_step_completed/onboarding_skipped,investigation_requested,coach_interview_started/coach_interview_completedandchallenge_joined. Events carry counts and catalog slugs only, never names, account ids or quest content. Every URL is redacted before it is sent: invitation tokens, recap share ids and UUIDs in the path become placeholders, and only campaign query parameters are kept, so an invitation link never reaches Plausible. Steps a server action decides (a quest actually created, an onboarding step actually saved) are sent from the server after the write lands, because those actions redirect and a submit-time event would count failures./privacynames Plausible under third-party services and says what it receives. Nothing is sent unlessNEXT_PUBLIC_ANALYTICS_DOMAINis set.You can download a copy of your data from
/settings/account. Download my data saves a JSON file, served by the newGET /api/me/export, of your data: profile and settings, the quests you own (including ones you deleted) with their tasks, deadlines and AI investigations, your comments, memberships, attendance, themes, achievements, challenge and jam entries, recaps, feedback and subscription status. Files you uploaded are listed by name and size rather than included, and other members' tasks and comments on a Party Quest you belong to are not yours to export. The route answers 401 when signed out and never caches the file. The privacy policy gains a "Getting a copy of your data" section, so access, like deletion since 3.8.0, no longer needs an email to us (#654).
Fixes
The Send feedback and quest Check In dialogs now close on Escape, keep Tab inside the dialog, and give focus back to whatever opened them when they close, like the app's other dialogs already did (#648).
Security
Signed-out callers can no longer call five signed-in-only RPCs, and signed-in callers can no longer read another account's free-tier usage (#653).
complete_quest(),dismiss_stall_notification(),snooze_stall_notification(),log_stall_check_in()andget_dashboard_stats()loseanon(each did nothing without a user, but answered rather than refusing).free_tier_actions_used_for(),free_tier_actions_remaining_for(),free_tier_daily_action_limit_for()— which took any user id — and the internal trigger guardassert_quest_accepts_task_writes()loseauthenticated: nothing in the app or the worker called them, and the definer functions that do are unaffected. Fourauthenticated_security_defineradvisor findings leftsupabase/advisor-allowlist.txtwith them.AGENTS.md, theadd-migrationskill,docs/schema.mdanddocs/ops/database-advisors.mdnow say a client-callable function needs an explicit grant (#653).
Technical changes· 13 entries for database, tooling, CI, tests and docs
Database
20261203120000_task_evidence.sqladds task completion evidence.quests.evidence_enabledis the owner's opt-in (default off, now also returned byget_my_active_quests()andget_quest_with_tasks()), andtask_evidenceholds either a photo in the new privatetask-evidencebucket (10 MB, JPEG/PNG/WebP) or the result of a location check-in: whether the device was inside the task anchor's radius and the distance rounded to 10 m. The coordinates themselves are never stored. Rows are written only throughadd_task_photo_evidence()andadd_task_location_evidence(), which require quest access, the opt-in and a done task (and location consent for a check-in); the table has no client INSERT or UPDATE grant, so a caption cannot be self-written. Evidence is readable by the quest party only, neveranoneven on a public quest, and deletable by its submitter or the quest owner. Photo captions run through a lease-bound queue modeled on the stall jobs (claim_/charge_/reprice_/complete_/fail_/skip_task_evidence_caption), charged to the submitter under the newevidence_captionledger kind with the usual one-image surcharge, never on DeepSeek, and refunded on reclaim or when the evidence is deleted before its caption is delivered (a delivered caption keeps its charge).refund_ai_credits()now takes a per-reference transaction lock, so two refunds of the same reference racing each other (the caption worker's failure path against a delete or a reclaim, or the stall jobs' equivalents) return the charge once rather than twice. A photo's caption also records a free-tier action (free_tier_ai_actions.kindgainsevidence_caption), so it counts toward the daily allowance; at the limit the photo is recorded with its caption skipped asdaily_limit. Also addsfind_orphaned_task_evidence_objects()and atask_evidencesection inexport_my_data(). Nothing in it can block or reverse a completion: evidence attaches after the task is done, even when that completion just finished the quest (#551).20261201120000_export_my_data.sqladdsexport_my_data(), which returns everything Questify holds that belongs to the caller as one jsonb document. It is SECURITY DEFINER, executable byauthenticatedonly, and refuses (42501) a caller with noauth.uid(), so the service role cannot run it either. It is a function and not a set of reads from the route for three reasons.quests.due_atandtasks.due_atare withdrawn from client reads (#570), and a deadline is the caller's own data. One statement is one snapshot, with no PostgRESTmax_rowspage to cut a list short. And the profile, feedback, challenge-attempt and jam-entry SELECT policies also admit staff, so a staff member reading as themselves would have exported other people's rows. Every section is keyed onauth.uid()explicitly instead of on RLS. The document covers the account email, profile and settings; every quest the caller owns, soft-deleted ones included, with every task and deadline; investigations on those quests, and those the caller started on a quest they can still open; their comments, deleted ones included, since the body is still stored; attachment metadata; seats, attendance, year themes, achievements, challenge attempts, jam entries, recurrence templates with their checklists (a step removed from the list included and marked, since a template's list can differ from the cycles already minted), recaps, guilds, feedback and subscriptions. It leaves out other members' content and other accounts' ids (confirmed_by,invited_by_id,initiated_by_id), the unsubscribe token, investigation pricing, feedback triage notes and storage paths.20261201130000_export_recurrence_template_tasks.sqladds thequest_recurrence_template_taskssection, which the PR #931 review found missing.supabase/advisor-allowlist.txtrecords the definer grant (#654).A new function in
publicis now private until its migration grants it (#653).20261202120000_function_execute_default_deny.sqlremoves the two default grants that made every function callable over/rest/v1/rpcthe moment it was created: Postgres' EXECUTE forPUBLIC(revoked from rolepostgres's global defaults) and Supabase's direct grant toanonandauthenticatedinpublic.service_rolekeeps its default, so the worker and trusted server code are unaffected. Existing functions keep their grants, because default privileges apply only to new objects andcreate or replacepreserves ACLs; a function dropped and recreated comes back private.supabase/tests/function_execute_grants.sqlproves the new default with a function created in the test, and pins withset_eqevery functionanoncan execute (14SECURITY DEFINERfunctions behind signed-out pages, plus 20 pure invoker helpers that run inside constraints and triggers) and everySECURITY DEFINERfunctionauthenticatedcan execute (104). Seven pgTAP files grant EXECUTE on their ownpg_temp/testshelpers, which are now private like any other new function (#653).
Tooling & Configuration
NEXT_PUBLIC_FEATURE_TASK_EVIDENCE(defaultfalse) gates task completion evidence, registered in.env.example,env.ts, the web Dockerfile,docker-compose.yml,turbo.jsonand the Playwright web server. The worker gains anevidence-captionsloop (EVIDENCE_CAPTION_POLL_INTERVAL_MS, default 15 s) that runs whenever an image-capable provider key is set; it is not flag-gated, because its queue is empty unless someone attaches a photo. The attachment reaper now also sweeps thetask-evidencebucket, and account deletion removes the account's evidence photos (#551).NEXT_PUBLIC_ANALYTICS_DOMAIN(optional, production only) turns on Plausible analytics (#652). Unset, the default, meansAnalyticsScriptrenders no script andtrackServerEvent()sends nothing, so local, preview, CI and Docker builds stay silent. It is registered in.env.example,apps/web/src/lib/env.ts(getAnalyticsDomain()), the webDockerfileARG/ENV, thedocker-compose.ymlbuild args andturbo.json'sbuildanddevenv lists.packages/uihas a shared Dialog primitive,@questify/ui/components/dialog(#648).Dialogis the overlay:role="dialog",aria-modal,aria-labelledby, backdrop click and Escape through oneonDismiss, and focus handling.DialogPanelis the height-bound panel (max-h-full, asizeprop for the max width,asChildfor a<form>).DialogHeader(optional) andDialogFooterare pinned, andDialogBodyis the one region that scrolls (min-h-0 flex-1 overflow-y-auto, with a requireddata-testid). The feedback, investigation prompt, check-in, complete-quest, delete-quest, location consent and Quest Pack preview dialogs moved onto it and dropped their hand-written copies of that markup.useModalFocusmoved fromapps/web/src/lib/a11y/to@questify/ui/hooks/use-modal-focus, since the primitive needs it and it has no app dependencies; its callers import it from there. The model persona picker keeps its whole-panel scroll for now.
CI/CD
The worker deploy in
release.ymlnow records itself in Sentry: once the worker is verified running, it adds theworkerproject to releasequestify@<version>and records aproductiondeploy scoped toworkerthrough Sentry's API (sentry-cliwould also list it as a deploy ofweb). Before, only the web build's plugin recorded a deploy, soworkershowed none and joined the release only on its first error. The step is skipped withoutSENTRY_AUTH_TOKEN/SENTRY_ORGand only warns if Sentry is unreachable;scripts/worker-deploy.test.mjspins it (#657).
Tests
Task evidence is pinned (#551).
supabase/tests/ai_credit_refund_concurrency.sqlshows a second refund of the same reference waiting for the first and returning nothing.supabase/tests/task_evidence.sql(52 assertions) covers the attach RPCs' refusals, a check-in storing only its result, evidence on the task that just completed its quest, the caption persona by tier, the one-image surcharge, the lease-bound charge, complete, fail and skip, and the refunds on reclaim and delete;rls.sqladds the party/outsider/anon matrix on a public quest. Worker Vitest covers the caption job's success, skip, refund, reprice, superseded and unreachable-host paths; web Vitest covers the evidence section, its Server Actions, the opt-in switch with the flag on and off, theTaskManagerwiring and account deletion's photo cleanup.e2e/task-evidence.spec.ts(live fixture only, like the attachment suite) covers the flag-off state, the owner's opt-in on the quest form, and a real upload to the private bucket on a done task followed by its removal.The analytics layer is pinned (#652).
analytics.server.test.tscovers the Events API request: the forwarded User-Agent and client IP, the Referer or fallback URL, dropped empty properties, the unset-domain no-op, and a failed send that never throws.analytics-script.test.tsxcovers the script's absence when the domain is unset, the manual build and the pre-load call queue.analytics-url.test.tspins the URL redaction, andanalytics-pageviews.test.tsxpins one redacted page view per navigation. Every call site asserts its payload, and that a refused or failed outcome sends nothing: the onboarding andcreateQuestActionsuites (a rolled-back quest is not counted), the Quest Pack browser and form, the New Quest creator, the coach interview and "Draft it for me" (once per interview), the investigation request, and a newchallenge-start-form.test.tsx.src/test/analytics.tsaddscaptureAnalytics()for component tests.supabase/tests/export_my_data.sql(26 assertions) showsexport_my_data()refusing anon, the service role and a session with no user. It exports soft-deleted quests and both kinds of deadline, and only the right investigations: those on the caller's quests, and those the caller started on a quest they can still open, with none from another member or from a quest the caller cannot open. It keeps the caller's deleted comments and none of anyone else's, and each template owner's recurring Quest checklist with its removed steps and none of anyone else's, and leaves out the attendance confirmer's id, the unsubscribe token and feedback triage notes. A staff account whose role reads other people's profiles and feedback exports only its own.rls.sqlpins the function's grants. Vitest covers the route's 401, the download headers and file name, the 500 without the database's message on a failed or empty export,getMyDataExport()and the filename helper, the settings link and the privacy section; the web coverage floors rise to 96.0 / 92.2 / 95.6 / 96.0 (#654).ui-dialog.test.tsxpins the Dialog primitive's structure, since jsdom has no layout: the classes that carry the bound, the actions outside the scroll region, overlay class overrides, every panel size,asChildon a form, backdrop and Escape dismissal, and focus moving in and back (including torestoreFocusRef). The feedback and check-in suites gain Escape cases, and theuseModalFocussuite moved toui-modal-focus.test.tsxwith the hook (#648).
Documentation
docs/analytics.mdis the event catalogue (#652): every event, which side sends it, when, and its properties. It also covers why Plausible, how URLs are redacted and which routes count as secrets, the property rules (no personal data or user content), the Plausible goal and property setup, the roadmap funnels each event serves, and how to add an event.docs/ops/deployments.mdlistsNEXT_PUBLIC_ANALYTICS_DOMAINas a production-only Vercel variable.docs/schema.mdgains a "Data export" section listing each key of the export, what it holds and what is deliberately left out, plus the migration-order row and the RLS-coverage note.docs/api-endpoints.mddocumentsGET /api/me/export, its headers and its error codes (#654).