3.4.0
Released
Features
The admin Audit Log can now answer "what changed last Tuesday, and who did it?". It gained From/To date inputs (UTC days, inclusive), Action and Target type selects listing what the ledger holds with counts, and an Actor field that takes a whole user id or the first characters an id chip shows — a pasted prefix used to match nothing. Rows name the actor and the target instead of eight-character ids, say what happened ("deleted role Moderator"), link the target to its role or persona drawer, account or feedback report while it still exists, and fall back to the name the entry recorded once it is gone; clicking an action or an actor narrows the current view to it, and each row's metadata opens in a collapsible block. Export CSV (this filter) downloads the filtered, sorted list — every page, up to 10,000 entries, ids and names side by side — from the new
GET /api/admin/audit-log/export, which requiresaudit_log.read, defuses spreadsheet formulas in cells, and marks a file cut at the limit with a header and a closing line rather than passing it off as the whole log. An unusable filter is ignored with a message saying which, rather than silently returning a different list. The sticky header, relative times and shared pager already came with #783 (#793).The admin Feedback list opens on the open queue and is searchable, sortable and bulk-editable, and a report's page keeps the list it was opened from (#792).
/admin/feedbacknow defaults to Open, with every status option counted for the current search, type and submitter ("Open (12)", "All statuses (51)", left uncounted rather than zeroed when a count fails); "All statuses" is?status=all, which the dashboard's "total" link and the Users row menu's "Feedback by user" now use, so the latter still lists every report from that account. A search box matches title text, or a whole user id or feedback id; Status and Submitted sort the table (as sort links on phones), status in triage order; status and type apply as soon as they change, so the Apply button is gone. Each row's title is its only link. A checkbox column and a bar pinned to the bottom of the screen mark a selection reviewed or resolved — resolved asks first, since each submitter who takes feedback emails is told — sending only the reports not already at that status, keeping any that failed selected, and announcing the result in a live region. On a report, the Feedback breadcrumb returns to the same filtered, sorted page (?return=), Previous and Next step through that list (the open queue when the page was opened from elsewhere) and keep their place after the report's status is changed, the status badge sits in the header and follows a save, Triage is a card like its neighbours with an inline "Saved." in an always-present status region, and Submitter and Metadata are one Details card — the submitter linked to the Users list, a link to their other reports, the page URL and last update, with the feedback id, user id and screenshot path under "Developer details".GET /api/admin/feedbacktakes?q=,?sort=created_at|statusand?dir=, returnscounts, and no longer echoes database errors.The admin AI Spend page shows every platform ceiling before anything else, keeps both reports on one month, and lets the account table be searched and sorted (#791). A status strip under the header carries one chip per budgeted feature for the month shown — "10% used", "90% used" in amber, "ceiling reached" and "no budget" in red, in the colours the table's bar uses — each a link to
#platform-spend, so the two states that mean calls are being refused are visible without scrolling instead of ~1,500px down; a failed read, an empty budget table and a month the report has no row for each get a chip saying so, because a strip that vanished on a failed read would hide the one thing it exists to show. One month picker, in the header, drives both reports through?month=— before this each section had its own picker and could quietly show a different month — and switching month restarts the account pages while keeping the search and sort; a stale?platformMonth=or?user=link is read as the month or the search. The account table has a GET search box (?q=, a display name in part or a user id in whole; the Users row menu now links here with?q=<id>), sort links on Actions, Refunds, Gross and Net (?sort=&dir=, largest gross first by default, the caption naming the order), the sharedPaginationcounting the matches, and a totals footer that stays the whole month's and says so when a search is on. Belowmd, where the table's rows are cards with no header row, a "Sort by" row of links (the new sharedSortLinks) carries the same sort, so a phone can sort at all; a searched page past the end says so and offers page one with the search kept, rather than claiming nothing matched. The ~300px of explanatory prose between the admin and each table is one sentence plus a collapsed "How these numbers work".stall_checkin, which the label map had missed, is now "Stall check-ins" rather than its raw key. (#791)The admin Personas list can be narrowed to one plan tier, provider or availability, and the admin Quest Jams list keeps ended Jams on their own tab and names the timezone its times are shown in (#790). On
/admin/personas, a GET form above the table filters by?tier=(Free · Pro),?provider=(one option per provider in the catalog, matched case-insensitively) and?show=(Active · Inactive · Recommended), so "which models are currently free to every account?" is the link?tier=freerather than a scan of a dozen rows; the header counts "N of M personas · Free · openrouter", an empty filtered list says the filters matched nothing rather than that there are no personas, and an edit link to a persona the filters hide still opens it.parsePersonaFilters()drops a tier orshowvalue the list does not offer, so a stale link lands on the full list. On/admin/quest-jams, the one flat list that accumulated every past Jam is split into "Live & upcoming (n) · Past (n)" tabs on?view=, defaulting to the first; a paused Jam stays on the first tab, since it has not ended and can be switched back on, and a Jam whose status could not be decided is shown there rather than on neither. Two admins in different zones saw different "Starts" times with nothing saying which zone either was in: the list now says "Times are shown in America/Toronto" (fromViewerTimeZoneinlocal-datetime.tsx, which printsUTCuntil the browser's zone is known — the same zoneLocalDateTimeprints before mount — so the page never names a zone its times are not in), and the drawer's Starts and Ends inputs are described as being in that zone and saved as UTC. The #784 tier badge, Recommended star and Live/Upcoming/Paused/Ended badge already covered the rest of the issue.
Technical changes· 7 entries for database, tooling, CI, tests and docs
Database
20261113120000_admin_audit_log_facets.sqladdsadmin_audit_log_facets(), which returns every distinctactionandtarget_typeinadmin_audit_logwith how many entries carry it, so the Audit Log's filters can be selects built from what the ledger actually holds — PostgREST cannotselect distinct, and a list kept in app code would miss actions written by SQL functions. It is SECURITY INVOKER: the ledger's ownaudit_log.readpolicy decides what is counted, so a caller without the privilege gets no rows, exactly as a direct read does. Closed toanon;supabase/tests/admin_audit_log_facets.sqlpins the grouping, counts, order and access, andrls.sqlasserts the grants (#793).20261112120000_feedback_status_rank.sqladdsfeedback.status_rank, a stored generated column — open 0, reviewed 1, resolved 2, wontfix 3 — and a(status_rank, created_at desc)index, so the admin feedback list sorts by status in triage order rather than alphabetically, which put resolved ahead of reviewed (#792). It is derived fromstatuson every write and no role can set it; neither offeedback's column grants names it.supabase/tests/feedback_status_rank.sql(9 assertions) pins each rank, the order, a rank following a status change and the column refusing writes;rls.sqlpins its INSERT and UPDATE column privileges.20261111120000_ai_spend_report_search_sort.sqldrops and recreatesuser_ai_spend_report()as(_month, _limit, _offset, _search, _sort, _dir), so the admin account table can be searched and sorted in the database rather than over one 50-row page (#791)._searchnarrows the rows to one whole user id or to the accounts whose display name contains the fragment (case-insensitive;%,_and\typed into the box match literally) and replaces the_user_idargument #786 added, since a pasted id is a search;_sortisgross(the default) ·net·refunds·actionsand_dirdesc(the default) ·asc, ties still broken byuser_id, and an unknown value raises 22023 rather than quietly rendering the default order under a header that says it is sorted. Thetotal_*columns stay the month's over every account, as they did for_user_id, so a searched row keeps its context and the dashboard tile reads the same figure; the new last columnmatching_accountscounts the rows the search matched over all pages, which is what the pager counts, and equalstotal_accountswithout a search. Still admins only and closed toanon;rls.sqlpins the single signature. (#791)
Tests
The Audit Log's filter parsing (calendar-day validation, the uuid-prefix range, inclusive UTC day bounds), the batched and capped export, the facets read, the CSV writer (quoting, formula defusing, the truncation line), target names and links, chunked name lookups, the export route's 401/403/400/500 paths and the page's selects, links, metadata and ignored-filter message are covered in Vitest (#793).
admin-list.test.tspins the feedback list's URL parsing and fallbacks, the hrefs and?return=round trip (including that only its query string is read), the API params, the literal title pattern, the tie-broken order and the keyset Previous/Next filters;admin-detail.test.tsthe neighbour and other-report reads, including a status-filtered list after the report's status is saved as another; a test pinsFEEDBACK_STATUS_RANKto the migration'sCASE; the list route tests cover search, id search, the status sort, the counts and a failed count; the list component tests the counted, default-open status filter, search, URL-kept sort and row links, and the bulk bar (skipping reports already at the status, the resolve confirmation, partial failure); the triage tests the always-mounted status region and the refresh; and a new detail page test covers Back, Previous/Next, the header status and the Details card (#792). Theapps/webcoverage floors move up to 95.4 / 91.6 / 95.2 / 95.4 (statements / branches / functions / lines), measured at 95.75 / 92.10 / 95.50 / 95.75 across 5163 tests.user_ai_spend_report.sqlgrows from 35 to 53 assertions: the single signature, a whole id in any case and a trimmed name fragment narrowing the rows andmatching_accountsbut not the totals, a literal wildcard, an id nobody has matching nothing, each sort ordering the fixtures as the arithmetic says, a tie on the sorted figure between accounts of different gross spend broken by id in both directions, and an unknown sort or direction raising 22023; the page tests cover the status strip's four chip states and its failed, empty and missing-month chips, the one picker driving both reports and keeping search and sort, the old?platformMonth=and?user=links, the sort links andaria-sort, the search form's hidden fields, the phone sort row and its links, a search that paged by its matches while the footer kept the month, a search nothing matched, a searched page past the end, a pasted essay being cut, and both notes being collapsed;links.test.tsanduser-ai-spend.test.tspin the URL builder and the RPC arguments; the live Playwright spec walks the strip, a search by id, a sort and the single picker (#791).persona-filters.test.tspins the persona filter parsing, matching, provider menu and count line;persona-list-filters.test.tsxand the panel tests cover the filter form, a filtered and an empty filtered list and the drawer opening a hidden row; the Quest Jams panel tests cover both tabs, the counts, a paused Jam staying current, a Jam with no status, the tab links keeping the rest of the query, the empty-tab messages and the timezone lines;queries.test.tspinsquestJamAdminView()andparseQuestJamAdminView();local-datetime.test.tsxcoversViewerTimeZoneserver-renderingUTC, naming the zone once mounted and falling back whenIntlcannot say (#790).