2.27.0
Released
Features
Users can delete their own account from
/profile. The new Delete account section opens a form that asks them to type their email address, and optionally why they are leaving; the Server Action then locks what they own against new members (one deletion attempt at a time; a second request is told the account is already being deleted), expires any open Stripe Checkout session and cancels every subscription that can still charge — on the mapped Stripe customer and on any other customer whose metadata carries the account's id, which a pre-idempotency race could have left holding a live subscription — reading every page (and refuses to go on if it cannot, including when it cannot read which Stripe customer is theirs — deleting the rows does not stop Stripe charging, and a Checkout completed after the delete would start a subscription nobody owns), deletes the user withauth.admin.deleteUser(), writes anaccount.deleteaudit row with no personal data, removes the attachment and feedback-screenshot files the cascade would orphan (after the delete, so a deletion that fails loses nothing; the worker's reaper now sweeps both buckets for whatever is missed), emails a confirmation to the old address, signs out and lands on the sign-in page with a notice./api/billing/checkoutrefuses an account whose deletion is under way, and expires a session it created if the deletion started meanwhile. Once the form is submitted its Cancel button is disabled, since a running deletion cannot be called back. While they own a Party Quest or guild that someone else still belongs to, the section lists each with a link instead of a delete button. Party Quest owners get a Hand over this quest control in the party panel to clear that without deleting the quest; guilds already had transfer. The privacy policy now describes the in-app path and keeps email as the fallback. Comments left on other people's quests, and a membership on someone else's guild roster, stay as "Former member" (the label comments already used) (20261022120000_account_deletion_roster_names.sqlrenames it and clears its invite address as the account's delete clears the link), and invitations the account accepted or was bound to keep no address (20261023120000_account_deletion_invitation_addresses.sqlclears it, names a Party Quest invitee "Former member" and revokes one still pending) (#718).With
NEXT_PUBLIC_FEATURE_GUILD_INVITESon, a guild's owner is now also emailed when somebody accepts a legacy in-app invitation — a pre-#661pendingmembership moved toacceptedbyacceptGuildInvitationAction— with the sameguild_member_joinedemail an emailed invitation or a redeemed claim code sends. Before this, that path told nobody. The accept now only moves a row that is stillpending, so the owner is emailed once per acceptance even when the button is pressed twice or two submissions race; the second still answers "You joined the guild." The email names the guild the updated row belongs to, not the id the browser sent. A failed send never fails the join, and the owner is still not told when a member declines or leaves. (#674)With
NEXT_PUBLIC_FEATURE_JAM_COPY_AIon, each upcoming cycle of a recurring Quest Jam gets its own title, description and theme, generated from its template, the template's theme andcopy_guidance, and the titles earlier cycles used — so the third occurrence no longer reads like a cron job. The worker's newjam-copyloop validates every answer (length, at least one letter or digit in every field, no links, no repeated title, a theme only where the template has one) and the database checks bounds again before a public row changes. A failed attempt pauses the loop for at least its idle poll instead of moving straight on to the next cycle, so an outage does not spend every queued cycle's first attempt in a row. Minting never waits on a provider: a cycle always appears with its template copy, and anything short of a valid answer — the flag off, no provider, an invalid reply, an exhaustedjam_copybudget, or the jam opening first — leaves that copy in place.JAM_COPY_MODEL_TYPEdefaults to the free router, which books nothing, and the loop runs only when it names an explicitprovider/model_idand the key for that provider is set — a value with no provider prefix, or any other key alone, reports it misconfigured; a catalogued paid model is charged to the platform'sjam_copyceiling and repriced to whatever actually answered. The flag is registered in.env.example,apps/web/src/lib/env.ts, the web Dockerfile,docker-compose.yml,turbo.json, the Playwright web server, and the release workflow, which copies it from Vercel to the worker like the other worker flags. (#548)Completed quests now show the Quest Chronicle beside the page header, with its generate or regenerate action available without scrolling past tasks. Owners who complete a quest while viewing it are offered a one-time dialog to generate the story; choosing later leaves the action available, and generation still requires an explicit click because it may use AI credits. The offer works after manual, task-driven, or attendance completion when Narrative Mode is enabled; its existing flag now reaches Turbo build and dev tasks and the Playwright web server. (#703)
With
NEXT_PUBLIC_FEATURE_WEEKLY_DIGESTon, everyone gets a plain weekly progress digest at 17:00 on Sunday in their own timezone: what they finished in the last seven days, what is still open oldest first, and which quests have had no activity for a fortnight, with one link to the dashboard. No model is involved — it is the non-AI baseline the AI review digest (#546) has to beat. A week with no open tasks and no completions sends nothing at all. Quiet hours delay it rather than skipping it, the profile gains a Weekly progress digest toggle, and each email carries its own one-click unsubscribe. (#660)
Fixes
An account could not be deleted if it owned a guild, or had a pending, declined or departed membership in anyone else's — even by an operator, and not only through the new self-service flow.
guild_members.user_idisON DELETE SET NULL, and the cascade's update brokeguild_members_accountless_is_accepted(a null account was allowed only on accepted or removed rows) or, on the owner's own row,guild_members_owner_is_accepted, rolling back the whole delete.20261021120000_account_deletion_guild_memberships.sqllets a closeddeclinedordepartedrow outlive its account, andclose_deleted_invitee_membership()closes a pending invitation asdeclinedwhen the invitee's account is deleted and leaves the owner row of a guild being deleted to that guild's own cascade; both act only once the account is really gone (#718).The Stripe webhook no longer retries forever for a deleted account. When the mapping row is gone,
resolveUserIdForEvent()falls back to the user id in Stripe metadata, and after an account deletion that id names nobody — so thecustomer.subscription.deletedevent that the deletion's own cancellation triggers failedapply_subscription_state()'s foreign key, answered 500, and would have been retried for days until Stripe disabled the endpoint. The metadata id is now used only if a profile still exists for it; a failed check still throws so the event is retried (#718).Admin feedback details now wrap long titles, report text, and submitter identifiers inside bounded columns, and the feedback list uses linked records on narrow screens instead of a wide table. This keeps both pages within phone widths while preserving the table on desktop. (#710)
The worker's feature flags now reach it under
pnpm devandpnpm build. Turbo passesNEXT_PUBLIC_*variables through only to packages it detects as Next.js apps, so the worker — plain Node — saw none ofNEXT_PUBLIC_FEATURE_STALL_DETECTION,_EVENT_REMINDERS,_DEADLINE_REMINDERS,_STALL_AIor_WEEKLY_DIGEST, and ran every one of those loops off whatever.envsaid. All five are inturbo.json'sbuildanddevlists, andscripts/worker-env-catalog.test.mjsno longer exemptsNEXT_PUBLIC_*names, so the next worker flag cannot miss them. Production was unaffected: the release workflow passes the flags to Fly directly. (#660)
Security
Production no longer holds an Anthropic API key. Each deployment now authenticates to Anthropic with workload identity federation: the host signs a short-lived OIDC token saying what is running (Vercel's
x-vercel-oidc-tokenrequest header for the web app, a token the worker mints over Fly's/.fly/apisocket, the job'sid-tokenfor GitHub Actions), and the app exchanges it atPOST /v1/oauth/tokenfor a ten-minute-to-one-hour access token bound to a federation rule in the Claude Console, sent asAuthorization: Bearerin place ofx-api-key. The newapps/web/src/lib/ai/anthropic-auth.tsandapps/worker/src/ai/anthropic-auth.tsdo the exchange (bounded at 30 s, with each caller waiting on it only for its own remaining budget — the web app's provider call then gets only what the exchange left of itstimeoutMs, so authentication cannot push a charged narrative past the route'smaxDuration, and a caller in a hurry gives up on a shared exchange without cutting it short for the others — and the worker's Fly token request at 10 s, so a stalled endpoint cannot hold the processing loop), cache the token per process, refresh it two minutes before expiry (serving the cached one when an early refresh fails, never one about to expire) and share a single in-flight exchange between concurrent callers; the worker asks Fly for a fresh identity token on every exchange because Fly's carry a single-usejti. A deployment is configured with three identifiers rather than a secret —ANTHROPIC_FEDERATION_RULE_ID,ANTHROPIC_ORGANIZATION_IDandANTHROPIC_SERVICE_ACCOUNT_ID(plusANTHROPIC_WORKSPACE_IDonly for a rule enabled in more than one workspace), registered in both env schemas,turbo.json,.env.example, the dev TUI'sMANAGED_KEYS(sopnpm dev:tui -- keyscan set them for a local run against a pulledVERCEL_OIDC_TOKEN), the Vercel project,fly.toml's[env]block and the repository's GitHub variables. Federation deliberately wins overANTHROPIC_API_KEYwhen both are set, so a stale key cannot mask a broken exchange, and a partial set of identifiers is refused bygetServerEnv()/getWorkerEnv()rather than silently falling back to the key.ANTHROPIC_API_KEYstill works, for local development, andisProviderConfigured('anthropic')counts a rule as credentials so the persona picker and the worker loops treat a federated deployment as configured. (#726)
Technical changes· 35 entries for database, tooling, CI, tests and docs
Database
20261017120000_account_deletion.sqladds what self-service account deletion needs beyond theauth.userscascade.my_account_deletion_blockers()lists the quests and guilds the caller owns that another account still belongs to —quests.owner_idandguilds.owner_idcascade, so deleting an owner would delete the whole Party Quest or guild for everyone in it; accountless seats and soft-deleted quests never block.transfer_quest_ownership()is the one exception toprevent_quest_owner_change(), through a transaction-localis_quest_ownership_transfer()flag: the caller must own the quest and hand it to an account-linked member seat, the outgoing owner stays as a member, and the year theme, recurrence link and stall nudge — all the outgoing owner's — are cleared; a Quest Challenge attempt or Quest Jam entry refuses.account_deletion_feedbackholds the optional exit reason with no user id and only a month, so it cannot be joined to theaccount.deleteaudit row; only the service role writes it and onlyaudit_log.readreads it. Both functions are closed toanon.20261018120000_account_deletion_lock.sqlmakes the blocker check hold until the delete: the delete runs seconds after the check, in GoTrue's own transaction, so someone joining in between would still have lost the shared quest or guild.lock_account_for_deletion()counts the blockers under a per-account advisory lock and, only when there are none, records a row inaccount_deletion_locks(RLS on, no policies, no client privileges) carrying a fresh attempt id, which it returns; while any lock exists for the account it refuses (55P03) a second attempt, so two deletion requests can neither share a lock nor take one over. While it exists, triggers onquest_party_members,guild_members,questsandguildsrefuse (55000) a seat or accepted membership for another account on anything the leaving account owns, a guild seat on its quests becoming accepted, and a quest or guild handed to it; each takes the same advisory lock first, so a join already in flight is counted and one that starts later is refused. The row does not lapse with age, however long the deletion takes:release_account_deletion_lock(_user_id, _attempt_id)— service role only, so the user cannot lift it through PostgREST mid-deletion — ends it after a failed deletion, and only for the attempt that took it (20261020120000_account_deletion_attempts.sql, which drops the signed-in zero-argument release);account_deletion_lock_held()(service role only) is checked immediately beforeauth.admin.deleteUser(); the row cascades away with the account; and20261019120000_account_deletion_review.sqladdsreap_abandoned_account_deletion_locks(), run hourly as thereap-account-deletion-lockspg_cron job, to release one a day old whose deletion died before doing either. The same migration addsfind_orphaned_feedback_screenshot_objects()(service role only), so a feedback screenshot the deletion fails to remove stays findable the way an orphaned attachment does. Deletes outside this flow, such as an operator's, are unaffected.my_account_deletion_blockers()now reads the sameaccount_deletion_blockers_for()the lock uses.supabase/tests/account_deletion.sqlcovers the blockers, every transfer refusal, the owner lock outside the transfer, and deleting anauth.usersrow that holds a Quest Jam entry (whose quest FK isRESTRICT), a seat and a comment;rls.sqlpins the grants and policies (#718).20261024120000_transfer_detaches_guild_seat.sqlmakes a quest handed to a guild seat keep its owner seat.transfer_quest_ownership()promoted the new owner's seat by changing only its role, so a seat seeded from a guild keptguild_member_id; deleting that roster row or the whole guild then randetach_deleted_guild_member_seats(), which left the quest's one owner seat with no account whilequests.owner_idstill named the user, and owner flows that find the caller's own seat stopped working. The transfer now clearsguild_member_idon the promoted seat, making it a direct seat — the owner belongs to the party by owning the quest.guard_attendance_roster(), which fixes the roster once attendance is confirmed and so refused any change to that column, accepts this one case: inside the transfer, the same seat on the same account losing its guild link.create or replacekeeps both functions' grants (#718).20261016120000_party_invite_cap_on_resend.sqlcloses a hole increate_party_invitation()'s 100-open-invitation cap. The cap was checked only when a new row was created, so re-sending an existing pending invitation skipped it — harmless for a live row, but an expired pending row is correctly not counted as live (#639 review), so re-sending one beside 100 live links minted the 101st, repeatable for as long as expired rows existed. The function now checks on every send and counts every live link except the row being re-issued, the shape #663 gavecreate_guild_invitation(): a live invitation can still be sent again on a full quest, while an expired one is refused until a place frees up. Nothing else in the function changes, andcreate or replacekeeps its grants. (#666)20261015120000_party_invited_user_set_null.sqlmakesquest_party_invitations.invited_user_idON DELETE SET NULL. It wasON DELETE CASCADE, so deleting an account erased every party invitation that account had answered, while the same event onguild_invitationsonly blanked the invitee — andinvited_by_id/revoked_by_idwere alreadySET NULLon both tables. The row is the audit of an invitation that was sent and answered, so it now outlives the account on both sides. Nothing that reads the column changes behaviour: it is only stamped on acceptance, so no pending row carries one, and the SELECT policy's invitee arm and the owner-notification lookup simply stop matching a NULL. The address the invitation was sent to stays on the row, as it already did for guilds. No data is backfilled. (#667)20261014120000_vary_recurring_jam_copy.sqlqueues recurring Quest Jam cycles for generated copy. An AFTER INSERT trigger onquest_jamsputs every template-minted cycle that has not started intoquest_jam_copy_requests(service role only, RLS on, no policies), and the worker leases it throughclaim_jam_copy()→charge_jam_copy_claim()→complete_jam_copy()/fail_jam_copy(), withreprice_jam_copy_claim()for a substituted model andreap_jam_copy_claims()for a dead worker. A failed or reaped attempt is not retried for 15 minutes, then an hour (next_attempt_at,jam_copy_retry_at()), so a provider outage has to last over an hour and a quarter before a request falls back rather than spending all three attempts in seconds. When thejam_copyceiling refuses a charge,defer_jam_copy_for_budget()parks a cycle that a claim at the next monthly reset could still take — one opening more thanjam_copy_claim_margin()(two leases, the claim's own margin) after it — until then, with its attempt returned, and falls back only one that opens earlier — so an exhausted month no longer condemns next month's cycles. The claim mints the credit reference and stores it on the row before any charge, and every path that does not deliver copy — a failure, a lapsed lease, a deleted jam, a jam that opened mid-answer — returns it throughrefund_system_ai_credits().quest_jam_copy_still_open()is the one rule the claim, the completion and the reaper share: still attached to its template, active, not started, nobody joined — so a retired template's queued cycles are expired rather than claimed, and a jam's copy never changes under the people who signed up, andquest_jam_entriesare never touched. Templates gain an optionalcopy_guidancefor the constraints a variation must respect.ai_credit_entries.user_idis now nullable fororigin = 'system'rows only (ai_credit_entries_user_unless_system), andcharge_system_ai_credits()accepts no account forjam_copyalone, because a jam is run for nobody; the spend still books against the existingjam_copymonthly ceiling.charge_system_ai_credits()now also refuses (22023) a model the persona catalog does not know, for every platform-funded kind:ai_credit_cost()prices a failed lookup at 0, the same as a free persona, and a background job has no tier trigger in front of it, so a typo'd or not-yet-catalogued paid model would otherwise have run outside the ceiling with no ledger row.complete_jam_copy()takesFOR UPDATEon the jam before it checks for entries, because an entry insert locks its jam onlyFOR KEY SHAREand a plain copy update does not conflict with that — without the lock a join and a rewrite could both commit. A hand edit wins: an AFTER UPDATE trigger onquest_jams(quest_jams_retire_copy_on_edit) expires a cycle's pending or generating request, refunding any reservation, when its title, description or theme is changed — so a correction made in/admin/quest-jamsis not overwritten by an answer built from the template.complete_jam_copy()settles its request before writing the jam, so its own rewrite does not trip that trigger, and locks the jam before the lease, in the same order as an edit, so the two cannot deadlock.ensure_quest_jam_cycles()is unchanged and now also runs hourly as theensure-quest-jam-cyclespg_cron job, so the next cycle is minted and queued without a page visit, andreap_jam_copy_claims(5, 3)runs every five minutes asreap-jam-copy-claims, so a claim charged by a worker that died is refunded even once the flag is off and no claim will run the reaper. (#548)Timestamps are now the correct instant in every session timezone.
set_updated_at()— shared by all 32*_set_updated_attriggers — wrotetimezone('utc', now()), which is the UTC wall clock with no zone; stored into atimestamptzit is re-read in the session's zone, so any session not running UTC storedupdated_atoff by its offset.20261012120000_utc_now_timestamps.sqlmakes itnow(), resets the 80timestamptzcolumn defaults that used the same expression, and re-issues the 21 other functions that stamped or compared atimestamptzwith it — quest and jam completion, guild membership, claims and invitations, the free-tier 24-hour window, the credit period, quest-jam cycles — each from its live definition with only that expression changed, so grants, owners andsearch_pathare untouched. The four functions that use the UTC wall clock on purpose, to bucket by UTC calendar day or month, keep it. No backfill: production runsTimeZone = UTCwith no role or database override, so its rows were already correct; a local or CI database built in another zone should be reset.supabase/tests/utc_now_timestamps.sqlruns asAmerica/Torontoand fails on the old definitions, and pins the four exceptions so a new function cannot reintroduce the pattern. (#699)20261013120000_weekly_progress_digest.sqladds the weekly progress digest:weekly_digest_deliveriesas its outbox and ledger,weekly_digest_content()for the frozen body (finished tasks are read from every quest the person still has, since finishing a quest's last task completes it; open tasks and stalled quests from active ones; "finished this week" is the seven local days ending at the most recent local Sunday 17:00, so the 17:25 and 18:25 runs report the same week and consecutive weeks neither drop nor repeat anything),weekly_digest_candidates()for who is due,enqueue_weekly_digests()for the hourly pg*cron jobenqueue-weekly-digests(25 * \* \* _), and the worker'sclaim_/complete\_/fail_weekly_digest_delivery(). A partial unique index on(user_id, week_start)makes a second report for the same week impossible, so re-running the enqueue sends nothing new.notification_preferencesgainsweekly_digest_emails, on by default, andupdate_my_notification_preferences()andset_notification_category_by_token()accept it and theweekly_digestcategory. The table holds email addresses and has RLS with no policies and no client grants; every new function is revoked fromanonandauthenticated. (#660)
CI/CD
ci.ymlruns four jobs instead of six. GitHub bills every job rounded up to a whole minute, and each paid its own checkout, Node/pnpm setup and install (45–60 s) before any work:📊 Coverage commentcost 195 billed minutes in September for about ten seconds of work a run, and🧹 Lint684 for about 31 seconds of lint and typecheck. The coverage comment is now the last step of🧪 Unit tests, which gainspull-requests: write— handed to the comment action alone, since its checkout no longer leaves the token in.git/configfor the install and tests; it reads the report the coverage step just wrote instead of downloading the artifact, and keeps the old guard, so forks and Dependabot still get no comment and it is posted only after coverage passes. Lint, typecheck and build now run on one install in🏗️ Lint, typecheck & build; typecheck and build are gated on the install rather than on the step before, so a lint failure still shows whether the other two pass. Lint feedback now arrives with the build rather than a minute in, and🌍 E2E, stillneeds: build, now waits for lint and typecheck too.✅ CI passkeeps its name — branch protection needs no change — and now needsbuild,testande2e, still accepting a skipped e2e. Every other job now gets a read-only token: the repository's defaultGITHUB_TOKENis read-write for nearly every scope, andci.ymldeclared nopermissions, so lint, build, e2e and the unit tests all ran pull request code holding it; the workflow now setscontents: readand only the unit test job widens it. Roughly 600 billed minutes a month at September's volume;docs/cicd.mdexplains the layout. (#723)A merge into
developno longer re-runsci.ymlorsupabase.yml: both now run on pushes tomainonly, and on pull requests intodevelopandmainas before. The pull request run had already built the merge commit, so the push run repeated the same tree wheneverdevelophad not moved in between — 520 billed minutes of CI and 85 of Supabase validation from 19 to 30 September, about 1,500 a month.supabase.yml's push tomainstays, becausedb-pushneedsvalidateanddb-testfrom that run before it pushes migrations to production. What this gives up is the one case only the push run caught: two pull requests that pass alone but break together. "Require branches to be up to date before merging" stays off fordevelop, since it would force a re-run each timedevelopmoves; the next pull request intodevelop, whose run builds on the broken result, and the up-to-date release pull request intomaincover it instead. (#722)codeql.ymlno longer runs on pull requests intodevelopor pushes todevelop. Those runs were 327 of September's and 1,464 billed minutes, 14% of the repo's Actions bill, and each one ended in a 7-day SARIF artifact and nothing more: this private repository has no code scanning, so the workflow runs withupload: neverand a finding reached nobody. CodeQL still analyses both languages on pull requests intomain(so every release and hotfix is scanned before it merges), on pushes tomain, and weekly, and can now be run by hand throughworkflow_dispatch. The weekly run now checks outdevelop: a scheduled workflow always starts on the default branch,main, which only moves at a release that was already scanned, so without the switchdevelopwould go unscanned until its next release pull request. That switch is read frommain's copy of the workflow and takes effect from the release that carries it. A finding introduced ondevelopnow surfaces at the next Monday scan or release pull request rather than on its own pull request. Each run writes its findings to the job summary through the newscripts/codeql-summary.mjs: counts by level and security band, then up to 50 findings, security first. It never fails the job: the step runs after the artifact upload withcontinue-on-error, and a finding does not turn the scan red. This is expected to save roughly 1,300–1,400 billed minutes a month (#721).The
🧪 Unit testsjob inci.ymlnow runs the web Vitest suite once per CI run instead of twice. It used to run the rootpnpm test— whoseturbo run testincludesapps/web— and thenpnpm --filter web test:coverageover the same suite again, although the coverage run already fails on a broken test and on a missed floor. The job now runspnpm test:dev-tui,pnpm test:scriptsandturbo run test --filter='!web'(the worker and any other workspace suite) as separate steps, then the coverage run as the only web run, saving about four minutes of each job — roughly 1,300 billed minutes a month at September's volume. The coverage artifact and the PR coverage comment are unchanged, and the rootpnpm testscript is untouched, so local runs still include the web suite.docs/cicd.mdexplains the split, anddocs/ops/deployments.mdand theprecommit-gateskill now describe it. (#720)model-catalog.ymlchecks Anthropic's model list without a stored key: the job now hasid-token: writeand a step that exchanges its GitHub OIDC token (audiencehttps://api.anthropic.com) for an Anthropic access token under the rule pinned torepo:12529572-Canada-Inc/questify:ref:refs/heads/main, passed toscripts/check-model-catalog.mjsasANTHROPIC_ACCESS_TOKENand sent as a bearer token. The step is skipped when theANTHROPIC_FEDERATION_RULE_IDrepository variable is unset, and a failed exchange is a warning that leaves Anthropic on the OpenRouter proxy for that run, so the monitor never fails for a reason the catalog cannot fix.release.yml's worker deploy fails its preflight whenfly.tomlcarries some but not all of the three federation identifiers (the worker would refuse the partial set at boot and restart-loop), no longer warns thatANTHROPIC_API_KEYis missing from the Fly app when the full set is there, and warns instead when the key is still set beside it, because federation wins and the secret is dead weight. (#726)The pull request Lighthouse audit now runs only when a pull request touches something it measures:
apps/web/**,packages/ui/**,packages/typescript-config/**, the root install and build inputs (package.json,pnpm-lock.yaml,pnpm-workspace.yaml,.npmrc,.nvmrc,turbo.json) or its ownlighthouserc.jsonand workflow. Before this, every pull request paid for a full install, build and eight-route audit — 215 runs and about 11% of the repo's billed Actions minutes in September — including ones confined to migrations, the worker, docs or the changelog, which cannot change the pages it loads. Those now start no run and get no sticky comment. Lighthouse is not a required check, so a skipped run blocks nothing;workflow_dispatchandlighthouse-production.ymlare unchanged. (#724)
Tests
scripts/ci-jobs.test.mjspinsci.yml's job layout: no standalone lint or coverage comment job; lint, typecheck and build in that order on a single install, with typecheck and build gated only on it; the coverage comment as the unit test job's last step withpull-requests: writeand the fork and Dependabot guard;✅ CI passby name withneeds: [build, test, e2e]; a workflow-widecontents: readwithtestthe only job that widens it andpull-requests: writethe only write scope; andpersist-credentials: falseon the unit test job's checkout. It also runsci-pass's own script: it passes when every job succeeds or e2e is skipped, and fails on a failed or cancelled build, a failed or skipped test job, a failed or cancelled e2e, or a result left unset. Eight of its eleven tests fail against the previousci.yml. (#723)scripts/workflow-triggers.test.mjspins whereci.ymlandsupabase.ymlrun: pushes tomainonly, pull requests intodevelopandmain, anddb-pushstill gated onneeds: [validate, db-test]and a push tomain, sodevelopcannot drift back into either push trigger and the production migration push cannot lose its checks. Restoringdevelopto either push trigger fails it. (#722)scripts/codeql-summary.test.mjspinscodeql.yml's triggers (pull requests and pushes tomainonly, the weekly cron andworkflow_dispatchkept), thedevelopcheckout on the schedule, both languages, and the summary step reading the directory the analysis writes withcontinue-on-errorafter the artifact upload. It also covers the script: rule-default levels, suppressed results dropped, GitHub's security-severity bands, security findings sorted first, paths decoded from their SARIF URIs (%5Bid%5D→[id]), table-cell escaping (backslashes before pipes, and code spans for rule ids and paths), the 50-row cap, and a missing SARIF directory giving a warning rather than a failure (#721).Anthropic authentication is pinned on both sides of the worker/web split:
anthropic-auth.test.tsin each app covers the key-vs-rule choice, the exchange request body, caching, the shared in-flight exchange, the two-minute refresh with a fresh identity token, serving the cached token through a failed early refresh and refusing one about to expire, the opaque-401 message pointing at the Console's authentication events, and (web) the file → request-header →VERCEL_OIDC_TOKENsource order (the per-request header first, because the environment value is cached for the life of the instance) plus a stalled exchange giving up inside the caller's budget and the provider call being skipped when the exchange spent it all; the provider tests show a federated deployment making the exchange and then the completion withAuthorization: Bearerand nox-api-key; the env tests refuse a partial set of federation identifiers; the worker loop tests run an Anthropic persona on a rule with no key at all;check-model-catalog.test.mjspinsproviderAuthHeaders()preferring an access token over a key; the worker's Fly path is exercised against a fake machines-API socket (the request shape, a JSON-encoded token, malformed JSON rejected rather than thrown out of the response callback, a refusal, a non-JWT body, a socket that never answers, an unreachable socket); and the dev TUI test checks the shape of each federation id. (#726)scripts/lighthouse-production.test.mjspins the pull request audit'spathsfilter: it must be exact files and<dir>/**globs only, must match web app, UI package, lockfile, toolchain and Lighthouse config changes, and must not match migrations, pgTAP, worker, docs, scripts, changelog, ESLint config or other workflow changes. (#724)Account deletion is covered end to end below the browser (#718):
lib/account/deletion.test.tspins the order that matters — every customer carrying the account's id found through Stripe search (all pages, a search failure keeping the account), open Checkout sessions expired and then every page of subscriptions cancelled on each before the delete, a failure in either (or in reading the billing customer) keeping the account with nothing removed, the audit, feedback, attachment and screenshot removal only after the delete — and that the audit row and feedback carry no personal data;actions.test.tscovers the typed-email confirmation, the database lock refusing on blockers, its release after a failed deletion, and the redirect;supabase/tests/account_deletion_lock.sql(36 assertions) shows the lock refusing every way of joining or being handed something while it holds, however old, refusing a second attempt, releasable only by the service role for the attempt that took it, and stopping once released or reaped at a day,account_deletion_cleanup.sql(11) covers the screenshot-orphan finder and the reaper job,account_deletion_cascade.sql(29) deletes an account that sits in everyON DELETE SET NULLcolumn, owns a guild and has pending, declined, departed and accepted memberships elsewhere, each left as "Former member", and invitations linked to it left with no address, and fails when a new such column lacks a fixture, andaccount_deletion_lock_concurrency.sql(9 assertions, two dblink sessions) proves a join in flight is counted, a join after the lock is refused, and a second deletion attempt racing the first is refused;rls.sqlgains eight assertions for the lock table and the new functions; the checkout route's refusal and its expiry of a session created mid-deletion, and the worker reaper sweeping the screenshot bucket, have tests; the party panel, the delete section, the login notice and the privacy page each have tests;billing/customers.test.tscovers the deleted-account webhook path.supabase/tests/quest_ownership_transfer_guild_seat.sql(14 assertions) hands two quests to a member's guild seat — one with confirmed attendance — and shows each promoted seat kept as the same row on the same account with no guild link, a fixed roster still refusing that change outside the transfer, and both owner seats keeping their account when the guild is deleted while a seat that was never promoted is still detached. Six assertions fail against the function before20261024120000_transfer_detaches_guild_seat.sql, and three fail without its attendance-guard exception (#718).supabase/tests/party_invitations.sqlgains two assertions (62 → 64): re-sending an expired invitation on a quest with 100 live links is refused with54000, and the quest still has exactly 100 live links afterwards. Both fail against the function before20261016120000_party_invite_cap_on_resend.sql, which let the send through and left 101. (#666)supabase/tests/invitation_invitee_fk.sql(7 assertions) pins everyauth.usersreference on both invitation tables asON DELETE SET NULL, and shows an accepted party invitation and an accepted guild invitation surviving the invitee's deletion with onlyinvited_user_idcleared, still readable to the quest or guild owner and not to a stranger;rls.sqlgains one assertion for theinvited_user_idrule on both tables. Both fail against the schema before the migration. (#667)apps/web/src/lib/guilds/actions.test.tspins the legacy in-app accept's owner email: sent once when the pending row moved, addressed from the row's own guild rather than the caller'sguildId, and never on a repeat accept (which still succeeds), a closed invitation, a failed re-read, a refused update, with email invitations off, or from declining or leaving.actions-roster.test.tspins the accept's query chain, including thestatus = 'pending'filter. (#674)supabase/tests/jam_copy_variation.sql(58 assertions) pins the queueing (template cycles only, never one already live, and a retired template's cycles expired rather than claimed), the lease (a stale lease charges nothing), the platform-funded charge with no account and the constraint that keeps user rows strict, the fallback on every failure path with the template copy left intact, the refund on failure, reaping and deletion, the jam that opens mid-answer, the worker-only grants, both cron registrations, the scheduled reaper refunding a dead paid claim with no claim after it, a hand edit retiring a queued or generating request (refunding it and refusing the late answer) while an update that leaves the copy alone does not, that an uncatalogued model is refused rather than priced as free, the 15-minute-then-hour retry spacing for failed and reaped attempts, and a budget refusal deferring a cycle that opens 30 minutes after the monthly reset but falling back one that opens inside the claim margin;supabase/tests/rls.sqlgains 10 assertions for the new access rules — the queue table's RLS, no client table privilege, every worker RPC and helper closed toanon/authenticatedand open to the service role, and account-less ledger rows invisible to and unwritable by a signed-in user;supabase/tests/jam_copy_concurrency.sql(9 assertions, two dblink sessions) proves a completion waits for a join in flight and then keeps the template copy, that a join waits for a completion in flight, and that a completion waits for a hand edit in flight and then writes nothing. Worker vitest covers the prompt and validator, charge-before-call ordering, repricing, the budget-exhausted fallback, and the flag gate, including that only the configured model's provider key enables the loop;scripts/worker-deploy.test.mjspins the new flag's deploy path. (#548)The pgTAP fixtures in
rls.sql,ai_credit_limits.sqlandquest_jam_seed.sqlthat stamped or compared atimestamptzwithtimezone('utc', now())use barenow(), and the add-migration skill's "compare to barenow()" caveat is narrowed to the one thing still true: the wall-clock expression is only for UTC calendar bucketing. (#699)The feedback Playwright flow now checks page width from 320px through 1440px with a long report URL, opens a feedback record from the mobile list, and completes test-user onboarding before checking admin access. (#710)
Added component coverage for the Quest Chronicle completion offer and a feature-gated Playwright flow that completes a quest, dismisses the offer, and checks the persistent action after reload. Raised the web statement and line coverage floors to 79.5%. (#703)
supabase/tests/weekly_progress_digest.sqlcovers the digest end to end in 72 assertions: the schedule and privileges, what each section of the report contains, the fourteen-day stall cut, local-date resolution across timezones, who is a candidate at which local hour, the empty-week skip, the opt-out row, quiet-hours deferral at both enqueue and claim, the address and deleted-quests skips, a quest completed during the week (its finishing task is reported, and completing the last active quest before the claim does not skip the report), consecutive weeks meeting exactly at the local Sunday 17:00 whichever hourly run sends, across a DST change, the retry and permanent-failure paths, and the one-click link.rls.sqladds eight assertions for the new table and functions. The worker gains 31 vitest cases acrossweekly-digest/render,deliverandloop. (#660)
Documentation
docs/cicd.mdgains a "Why a merge into develop runs no checks" section — what the push runs cost, whysupabase.ymlkeeps its push tomain, and the recorded decision to leave "Require branches to be up to date before merging" off fordevelopand rely on the next pull request and the release pull request instead — with a matching note under thedevelopbranch-protection rule. The workflows table now gives both workflows' real triggers;supabase.yml's row used to say only "push/PR touchingsupabase/**". The CI/CD inventory indocs/ops/deployments.mdno longer lists adeveloppush amongci.yml's triggers. (#722)docs/cicd.mdgains a "Why CodeQL does not run on develop" section: what each trigger scans, why the weekly run checks outdevelop, the latency trade-off, and where findings now appear. The workflow table row is updated, and themainbranch rule now names the twoAnalyzechecks, which run on every pull request intomain. Thecodeql.ymlentry indocs/ops/deployments.mdalso describes the current triggers; it had claimed CodeQL ran on pushes and pull requests todeveloponly (#721).docs/ops/deployments.mdgains an "Anthropic: workload identity federation, not a key" section — the issuer and rule each workload's token is matched against, where the three identifiers live for Vercel, Fly and GitHub Actions, that federation wins over a leftover key, that every denial reads "Authentication failed" on the wire and the Console's authentication events tab names the reason, and the Vercel prerequisites (OIDC federation on in team issuer mode; two-hour production tokens).README.md,docs/ai-investigations.mdand.env.examplepoint at it from the provider-key rows. (#726)docs/cicd.mdgains a "Why Lighthouse skips some pull requests" section listing the filtered paths and why each is in or out, and warning that a path-filtered check stays pending, so the filter must become a job-levelifbefore Lighthouse can be made a required check. The workflow table row says which pull requests it runs on. (#724)docs/notifications.mdcovers the legacy in-app guild accept in the owner join email section: why it filters its own UPDATE onpendingrather than claiming, what a repeat accept answers, and that the guild comes from the updated row. The "sends nothing" note it replaces is gone. (#674)docs/ops/scheduled-jobs.mdgains theenqueue-weekly-digestsjob and a "Weekly progress digest (#660)" section beside the other three, covering the local-Sunday selection, the week key, the empty-week rule and why the report covers only quests the person owns. (#660)docs/schema.mddocuments the digest: the migration-order row, aweekly_digest_deliveriessection with its service-role-only access model and theweekly_digest_emailsopt-out, the six functions under Helper functions, and the RLS coverage entry. (#660)