diff --git a/components/vacations/vacations-reports.tsx b/components/vacations/vacations-reports.tsx
index 9cdfe30ac..2c86f2a7c 100644
--- a/components/vacations/vacations-reports.tsx
+++ b/components/vacations/vacations-reports.tsx
@@ -63,7 +63,16 @@ export function VacationsReports() {
if (tripsError || expensesError || budgetsError || scoresError) {
return
;
+ // The page's heading stays on screen when there is nothing to report yet:
+ // an empty state is not a reason for the route to lose its
.
+ if (trips.length === 0) {
+ return (
+
+
{tr('vacationsReports.vacationReports')}
+
+
+ );
+ }
return (
diff --git a/finalaudit.md b/finalaudit.md
index 363ebc485..4804ed6c4 100644
--- a/finalaudit.md
+++ b/finalaudit.md
@@ -651,6 +651,7 @@ IDs link each route to its row in the Session A register (`PAGE-`) and the Sessi
- Started: 2026-09-12T12:41:52.12Z
- Last Updated: 2026-09-27 (e70 parser live/public+native acceptance and local full gates pass; hosted/page-integration gates pending)
+- Page audit (every page on bubaly.com, shared by all bots): see **"Page audit — every page on bubaly.com"** at the end of this file — batches to claim, findings P-01…P-10, and a status row for all 398 routes.
- Released: **#541 merged to `main` at `533554be` on 2026-09-26 18:55Z** (merge commit, 242 commits). `main`'s CI on that head is green in all four jobs — Typecheck · Lint · Test · Build (unit tests on three host zones), Mobile, Database (migration replay, 68 boundary probes, re-apply onto an existing schema) and E2E. Production serves it: `GET https://www.bubaly.com/api/build-info` answered `{"revision":"533554be…"}` at 19:13Z, and `/api/health` answered database, auth and service-role **ok** and `status: degraded` because four feature secrets are unset in the production runtime (see Critical Blockers). **Then #580 merged to `main` at `7e54596d` on 2026-09-26 20:21Z** (the units verified after #541: AUDIT-011's 39 re-controlled probes, SEC-009, m6/m9/m12/m30/m0/m28+m29/m42+m43/m18/m35, migrations 0343 and 0349/0352/0360 unapplied and in the ledger); `main`'s CI on that head failed one E2E case (`phone-auth-http` durable-session close) that passed on the next `main` run untouched, and production answered `{"revision":"7e54596d…"}`. **Then #579 merged at `671c5f6a` on 2026-09-27 00:00Z** (another session's pass C1-K: member-write boundaries 0344–0380, trust fail-safes; recorded by that session in the *Release · #579* section below, with the production-migration blocker at 0177) and #582 at `6ff770da`, its release note. Production answered `{"revision":"6ff770da…"}` at 00:44Z, `/api/health` still `degraded` on the same four missing secrets. **Then #581 merged to `main` at `dcc0b42b` on 2026-09-27 01:41Z** (this branch's second tranche: migrations 0381–0387 unapplied and in the ledger, the remaining SRV-001 medium leads, AUTHZ-011, SEC-008, SEC-009, AUDIT-011's 39 re-controlled probes, money in the reader's locale from billing to the marketplace; merged with #579's 0344–0380 after a renumber). Every check was green on the head `3e0e5fc3` — E2E once re-run after the same phone-login durable-session hang `main` had shown on `7e54596d`, recorded on the PR. When it failed a third time (on #583's `fb033b8e`) it was root-caused rather than re-run: the login form's "Continue with phone" button rendered ENABLED from the server with no handler, and once hydrated its handler refused a not-yet-mounted form, so a tap in that window did nothing — the spec clicked right after `domcontentloaded` and waited 120 s for a phone field that never opened. It is a product defect, not only a test one (a family on a slow phone tapping the primary way in got nothing). The phone button, and the signup form's three entry buttons with the same shape, are now held disabled until the form mounts, as the email fieldset already was; `tests/e2e/login-readiness.spec.ts` pins it (red with the fix reverted: "Expected: disabled, Received: enabled"). Production answered `{"revision":"dcc0b42b…"}` at 01:46Z; `/api/health` database, auth and service-role **ok**, `status: degraded` on the same four unset secrets (PROD-ENV). The owner's "Supabase production migrations" workflow ran on the push and will stop at 0177 as before (PROD-DB-0177). **Then #584 merged at `0306c985` on 2026-09-27 02:20Z** (another session's C1-K-56: a notification for someone else is written by Bubaly, not by a member; `0388` unapplied), and production answered `{"revision":"0306c985…"}` at 02:45Z, as that session recorded. **Then #556 merged at `2eb62151` on 2026-09-27 10:51Z** (the four-worker audit branch: its finding IDs, and migrations `0406`–`0418` less `0412`, `0413` and `0417`, all unapplied and in the ledger). This session closed it out: it merged `main` into the branch, renumbered its migrations out of the range `main` had taken, fixed `releaseNumber`, which still called a deadline wrapper `main` had renamed, and moved two ordering guards onto the shared source-order helper. Every check was green on its head `d99faef0`. Three of the branch's decisions were flagged on the PR for a reviewer and stay the owner's to confirm: `0416` changes who may read a journal entry that is not marked private (its owner, or a manager, per `main`'s 0364 rule, where before any family member could); the fallback phone number now needs a country code; and the behaviour-log policy question (`0417` stays dropped). Production answered `{"revision":"2eb62151…"}` at 11:00Z; `/api/health` database, auth and service-role **ok**, `status: degraded` on the same four unset secrets. `main`'s own CI on `2eb62151` finished with every job green except one E2E case: `phone-auth-http` "held genuine SMS verification cannot replace logout" timed out at 120 s, and on its retry (1,295 passed). It is the pre-hydration phone tap root-caused on #583 (`requestCode` presses "Continue with phone" right after load, and before hydration that tap did nothing). The fix, `1f52f00b`, is on #583 and not yet on `main`, so this red has its fix waiting in the open PR rather than an unknown cause. **This is a deployment, not a readiness declaration**: no migration from `0318` on has been applied to production, and `PRODUCTION READY` stays **NO**. **Then #548 merged to `main` at `338b6b12` on 2026-09-27 ~11:55Z** (another session's PR: member-write boundaries `0426`–`0443`, unapplied; recorded by that session in its own sections). Production answered `{"revision":"338b6b12…"}` at 12:02Z. **Then #588, #589 and #590 merged** (a parallel session's live page audit: 16 blog heroes that 404'd, the review page's seeder text, white on danger at 2.80:1), **and #583 merged to `main` at `06dd3f7e` on 2026-09-27 12:48Z** after every job was green on its head `370fc8a8` (Typecheck · Lint · Test · Build, E2E, Database, Mobile) — which also brings `main` the phone-login fix its E2E had been red on. `main`'s CI on `06dd3f7e` is green. Production answered `{"revision":"06dd3f7e…"}` at 13:02Z; `/api/health` database, auth and service-role ok, `status: degraded` on the unset feature secrets. See *Page Audit* above for the per-page state. **Then #591 merged to `main` at `7563462e769386ad5a3cea53e82337cdfc3d9294` on 2026-09-27 16:01:34Z.** Its source head `e798814125c41465158d762177589df10b1612f2` passed all four jobs in CI run `36329827922` (Web completed 15:54:07Z; E2E completed 15:57:45Z). Those checks cover that incoming source, not the combined #592 worker revision. The incoming page-audit sections and their recorded per-page outcomes are preserved below; exact deployed revision and combined-source gates remain to be verified.
- Page-audit release record from PR #593 (merge timestamp reconciled to the GitHub event; its observed production time remains unchanged): **Then #591 merged to `main` at `7563462e` on 2026-09-27 16:01:34Z** (this session's page audit: the three-check ledger and every fix it found — titles, not-found paths, alias redirects, grid overflow, the calendar's realtime channel, relative-time hydration, and the signed-in phone pass: 0 axe violations on all 393 signed-in routes in the third crawl), after every job was green on its head `e7988141`. Production answered `{"revision":"7563462e…"}` at 16:06Z; `/api/health` database, auth and service-role **ok**, `status: degraded` on the unset feature secrets. The signed-out production re-crawl that would move its 358 rows from *fixed, live* to *clean* has not been run: the command was refused by this environment's permission check, and is left for the owner (see *Page Audit*).
- Total Audit Items: 14187 — all 14,186 reconciled IDs retained, plus catalogue-test SUPPORT-2D36C3920F6A. The existing media-test SUPPORT-302F04E15861 / SUPPORT-BC0F2BFDBFD6 / SUPPORT-4CE6A54076E4 records remain IN PROGRESS. The prior reconciliation retained all 14,180 incoming IDs, restored COMPONENT-0337E00DA6ED and added both worker fixture IDs; no record was removed.
@@ -48279,6 +48280,12 @@ keeps its client; 2 of 4 fail with the service change reverted. Fresh replay
pass (3 fail only on this container's Node 22). Inert in production until
F-001, like every migration after 0177.
+Released: PR #584 merged to `main` as `0306c985` after every check passed on
+its head (Database replay and probes, unit, build and the full E2E matrix);
+`www.bubaly.com/api/build-info` served that revision at 2026-09-27T02:45Z. The
+application half (`notify()` and the generate route writing as the service
+role) is therefore live; 0388 waits on F-001 with the rest.
+
## Swept clean · the API routes this file never named
The C1 brief listed routes the audit had never named; 13 remained on this
@@ -48469,3 +48476,729 @@ produced three files that passed 64 probes and were rejected on review.
Pending. Not withheld for want of work on this branch — withheld because
PROD-001 is a human action and SEC-001's bucket half, I18N-001 and the two
Google client-secret rotations are the owner's.
+
+## Page audit — every page on bubaly.com (P-series, opened 2026-09-27)
+
+Every page the site serves, opened in a real browser and recorded here with
+what it did. Public pages are checked against production
+(`https://www.bubaly.com`); signed-in pages against a full local stack built
+from `main` (Supabase from `supabase/migrations`, `next build && next start`),
+because this audit has no production login and must not create data there.
+
+### How several bots share this (Claude and Codex)
+
+1. **Claim before you start.** Pick a batch from the table below, put your
+ session id in *Owner*, set *State* to `🔄 claimed `, and merge
+ that one-line change to `main` on its own before doing the work. A batch
+ with an owner is not yours; a claim with no commit touching the batch for
+ six hours may be taken over, noting whose it was.
+2. **Crawl with the shared tools.** `scripts/audit-live-pages.mjs` (#588)
+ sweeps a deployment signed out and follows links. `scripts/page-audit.mjs` (read-only) loads
+ each path in Chromium and records status, final URL, uncaught errors,
+ console errors, failing same-origin requests, `` count, horizontal
+ overflow, and text that should never render (`undefined`, `NaN`,
+ `[object Object]`, an error boundary, a raw i18n key).
+ `scripts/page-audit-report.mjs` turns its output into the rows below.
+ - Public, production: `node scripts/page-audit.mjs --base https://www.bubaly.com --sitemap --out prod.jsonl`
+ (add `--paths file` for routes the sitemap does not list, `--mobile` for 390 px).
+ - Signed in, local: `supabase start`, `next build && next start -p 3107`,
+ sign a test account in, save its Playwright storage state, then
+ `--base http://localhost:3107 --storage state.json`.
+3. **A page is ✅ PASS only when** the crawler found nothing at 1280 px *and*
+ at 390 px, and a person or bot looked at what rendered and saw nothing
+ wrong. A flag the crawler raised that is not a defect (a real OAuth scope
+ that looks like an i18n key, say) is recorded with the reason, not
+ silently dropped.
+4. **Fix before moving on.** A finding becomes a `P-NN` record under
+ *Findings*, is fixed in the same batch, is re-crawled clean, and only then
+ is the row marked `✅ FIXED`. Anything that cannot be fixed from code (an
+ operator step, an owner decision) is `⛔ BLOCKED` with the reason.
+5. **Ship often.** Small PRs, merged to `main` as each batch's fixes are
+ green, so production gets them the same day.
+
+### Batches — claim one before you start
+
+| Batch | Scope | Owner | State | Last run (UTC) |
+| --- | --- | --- | --- | --- |
+| B1 | Public marketing + legal pages, production (sitemap + routes the sitemap omits), 1280 and 390 | session_01KRUgA6hD6QgzmtpSP6TUmP | ✅ done, first pass | 2026-09-27 12:20 |
+| B2 | Every blog article in the sitemap (1,049) and `/blog`, production, 1280 and 390 | session_01KRUgA6hD6QgzmtpSP6TUmP | ✅ done, first pass | 2026-09-27 12:20 |
+| B3 | Sign-in, sign-up, kid login, recovery, public token pages (`/gift`, `/pay`, `/s`, `/f`), production + local | session_01KRUgA6hD6QgzmtpSP6TUmP | ✅ done, first pass | 2026-09-27 12:20 |
+| B4 | Every signed-in family route (`/dashboard/*`, `/family`, `/wallet`, `/marketplace`, `/guardian`, `/missions`, `/kids`, …) as a Family+ parent and as a trial parent, local, 1280; the Family+ run also at 390 for the pages a fix touched | session_01KRUgA6hD6QgzmtpSP6TUmP | ✅ done, first pass | 2026-09-27 12:20 |
+| B5 | Every `/admin/*` route as a super administrator, local, 1280; fixed pages also at 390 | session_01KRUgA6hD6QgzmtpSP6TUmP | ✅ done, first pass | 2026-09-27 12:20 |
+| B6 | Interaction pass: every primary control on every signed-in page (submit each form, open each dialog, each tab), not only the render. **B6a** — open every tab, menu, disclosure and dialog opener (`page-audit.mjs --interact`, local only, never a submit or a destructive button). **B6b** — submit each form | session_01KRUgA6hD6QgzmtpSP6TUmP (B6a) | ✅ B6a done (278 family routes as a Family+ parent, 1,187 clicks; P-09, P-10 found and fixed); B6b open, claimable | 2026-09-27 17:30 |
+| B7 | The same routes as a child and as a teen (role-gated views, `/kid-login` sessions) | session_01KRUgA6hD6QgzmtpSP6TUmP | ✅ done, first pass (teen + child accounts in the Family+ household, 278 routes each, 1280 px; `/kid-login` PIN sessions not yet crawled) | 2026-09-27 13:30 |
+| B8 | The other ten locales (`en-GB`, `de-DE`, `es-ES`, `es-MX`, `es-US`, `fr-CA`, `fr-FR`, `it-IT`, `nl-NL`, `pt-PT`): every public page, and the signed-in pages B4 lists | session_01KRUgA6hD6QgzmtpSP6TUmP (public half) | 🔄 public half done (41 pages × 10 locales, production); signed-in half claimed 2026-09-27 17:40 (278 family routes × 10 locales, local) | 2026-09-27 12:55 |
+| B9 | Signed-in pages against production itself (needs an operator-provided test household; this audit has no production login and must not create data there) | — | ⛔ needs an operator | — |
+| B10 | Signed-in pages at 390 px for every route | session_01KRUgA6hD6QgzmtpSP6TUmP | ✅ done (round 2: 278 family + 78 admin + 37 id-based routes) | 2026-09-27 12:55 |
+
+"First pass" is what the crawler measures: the page loads and renders
+without an error, a failing request, a broken layout or a missing heading,
+and someone looked at the result. It is not B6: a page can render perfectly
+and still have a button that does the wrong thing. B6–B8 and B10 are where
+the next bot should start.
+
+### Findings
+
+> **Reconciled with #591** (another session's page audit, merged to `main` at
+> `7563462e` while this one was open; its rows are in the release table near
+> the top of this file). Four findings here were found and fixed there too,
+> independently. Where both fixes touch the same code, `main`'s is kept:
+> P-03's per-subscription channel is #591's `ownChannel` (a port of #587's
+> C1-S9-94), and this branch's guard now asserts that instead. P-04's shared-`Modal` change is withdrawn: #591
+> had tried the same change and dropped it when an E2E journey timed out,
+> and fixed the vacations dialog at its call site, which this branch now
+> keeps. P-01 (grid columns) and P-05 (the super-admin preview) were fixed
+> at different layers — #591 at the page, this branch in the grid templates
+> and the endpoint gate — and the two merged cleanly and hold together: the full unit suite on the merged tree
+> passes (20,710 tests). #591 also fixed the not-found #419 that this section
+> calls framework behaviour below: a missing record in the app is now
+> answered, not thrown (`tests/a-missing-record-in-the-app-is-answered-not-thrown.test.ts`).
+
+**P-01 · Medium · A wide table widened the whole page (five admin pages).**
+`/admin/admins`, `/admin/content`, `/admin/marketing/content`,
+`/admin/support-tickets` and `/admin/users` overflowed a 1280 px viewport by
+118–417 px. Their tables already scroll inside `overflow-x-auto`, but the
+page grid's `1fr` column has an automatic minimum of its content's
+min-content width, so a 720 px table widened the column instead of
+scrolling. Every main+sidebar grid (`grid-cols-[1fr_Npx]`, 27 files) now
+uses `minmax(0,1fr)`. Re-crawled clean at 1280 and 390. Commit 46468cbd.
+
+**P-02 · Medium · Sixteen dead blog photos (27 of 1,063 sitemap pages).**
+*Found in parallel by session_01TRY21ZKsFrfB3qtoP972A4, whose identical fix
+merged first as #588 (with `scripts/audit-live-pages.mjs`, a signed-out
+sweep that also follows links and checks every signed-in route redirects to
+`/login`); this branch's copy of the fix was dropped at merge in favour of
+#588's, and the finding is recorded here once.*
+Lorem Picsum has no image under ids 138, 148, 150, 245, 246, 262, 489, 601,
+624, 632, 636, 720, 843, 850, 963 and 968; `/_next/image` answered 404 for
+every hero and related-article card using one. `freeLicensedImage`
+(`lib/blog/posts.ts`) drops them as it already dropped an unverified host,
+and `` draws the cover. Read-side on purpose: it is live the
+moment it deploys, where a data fix would wait on F-001.
+`tests/blog-cover-free-images.test.ts` (#588). (Wikimedia hero URLs could not be checked from this sandbox — the
+host answers 429 to it — and production's own `/_next/image` requests for
+them all succeeded in both crawls.)
+
+**P-03 · High · `/dashboard/calendar` rendered its error screen.** The
+calendar and its busyness heatmap both subscribed to `calendar_events` under
+the topic `calendar_events:`. realtime-js returns the channel
+already open under a topic, so the second `.on()` ran after the first
+`.subscribe()` and threw "cannot add `postgres_changes` callbacks … after
+`subscribe()`", which the page's error boundary caught; and the first
+widget to unmount removed the other's subscription. `useRealtimeQuery` now
+opens its channel under a per-hook topic (`useId`), as the sidebar's unread
+counter already did.
+`tests/two-widgets-on-one-table-do-not-share-a-channel.test.ts` pins the
+library behaviour and the fix. Commit 0ec8fa6c.
+
+**P-04 · Medium · `/dashboard/vacations/new` failed hydration on every
+load (React #418).** `Modal` returned `null` on the server but rendered its
+portal during hydration (`typeof document` is already defined then), so a
+dialog open at first paint mismatched and the whole route re-rendered on
+the client. `Modal` now waits for `useHydrated()` (a `useSyncExternalStore`
+flag: false on the server and during hydration) and hands the same flag to
+`useDialogBehavior`, so the focus trap attaches when the dialog actually
+appears. Commit 0ec8fa6c. **Withdrawn in favour of `main`'s fix.** #591 made
+the same `Modal`-wide change, withdrew it when CI's weekly-meal journey timed
+out on it, and fixed the route at its call site (the "New trip" dialog opens
+after mount). This branch now keeps `main`'s `Modal` untouched (and drops
+`useHydrated`), so the route is fixed once, the way CI accepted.
+
+**P-05 · Low · A super administrator's feature pages called endpoints that
+refused them.** `requireFeature`/`requirePlanLevel` let a super admin
+through; `refuseUnlessEntitled`, on the endpoints behind those pages, did
+not, so `/dashboard/autopilot` and `/dashboard/briefing` rendered and then
+got 403 from `/api/autopilot/scan` and `/api/ai/briefing` for a super admin
+whose own family is below Family+. The endpoint gate now asks the same
+question — only after the family was refused, and a lookup that fails
+stays a refusal. A real Family+ family was already served both (verified
+with a Family+ account: all 23 Family+ routes pass).
+`tests/a-super-admin-page-and-its-endpoint-agree.test.ts`. Commit 87fcdd53.
+*Corrected after review on #585:* the gate asked `isSuperAdmin()`, which
+builds its own cookie-bound client, while `/api/ai` authenticates a bearer
+token first and hands the gate that client. An ordinary bearer with an
+admin's cookie in the same browser was let through; an admin's bearer with
+no cookie was refused. `isSuperAdmin` now takes an optional client
+(defaulting to the cookie session, so every page is unchanged) and the gate
+passes the `db` the endpoint authenticated. The test runs the real
+`isSuperAdmin` against fake bearer and cookie clients: the mixed-credential,
+bearer-only and "never consults the cookie" cases fail with the fix
+reverted (4 of 7).
+
+**P-06 · Low · Seventeen routes had no ``, five had two.** Eleven
+single-use modules drew their page title as an `` (now an ``, same
+classes, so nothing moves on screen); the trip reports empty state dropped
+its heading; `/f/[id]` titled its form with an ``; the assistant (whose
+module also renders inside the AI orb), groceries and new-mission routes
+had no title heading and get a screen-reader `` from an existing
+catalogue key; five admin marketing pages repeated the section layout's
+`` and now use ``. The crawler waits up to 3 s for a heading before
+counting it, since many pages draw a skeleton first. Commit 3a3ec37b.
+
+**P-07 · Low · A redirect made the sidebar give up on the member's saved
+layout.** The sidebar's preference read is a server action; a client
+redirect landing while it is in flight (e.g. `/dashboard/vacations/` →
+`/overview`) cancels it with a network `TypeError` while the sidebar stays
+mounted, so it logged "preference read failed" and closed its write gate
+on a read that never failed. A network `TypeError` is now retried twice
+(0.8 s, 1.6 s) before it is reported. Two cases added to
+`tests/a-sidebar-layout-we-could-not-read-is-not-one-we-may-overwrite.test.ts`.
+Commit 3a3ec37b.
+
+**P-08 · Medium · A visitor's first page could lose its analytics event to a
+503 (production).** `/api/mkt/track` read the visitor by `anonymous_id` and
+inserted it when absent. A first page that sends two events at once (the page
+view and the one beside it on `/ai`) ran both reads before either insert; the
+loser hit `mkt_visitors_anonymous_id_key` and answered 503 — a console error on
+the page and a lost touchpoint. Reproduced locally on every signed-in `/ai`
+load and on production in the locale pass (`/ai` es-MX, `/mobile` de-DE, both
+answered by `server: Vercel`). A unique violation (23505) now re-reads the row
+the other request created and records against it; any other create failure,
+or a winner that cannot be read back, is still a 503.
+`tests/a-first-visit-with-two-events-records-both.test.ts` (4 cases; the race
+case fails with the fix reverted).
+
+**P-09 · Low · `/favicon.ico` answered 404 (production).** Pages name their
+icons in `` (`/icons/icon-32.png`, `/icons/icon-192.png`), but a browser
+still asks for `/favicon.ico` where there is no page to read them from, and
+link unfurlers and search engines ask for it first. `https://www.bubaly.com/favicon.ico`
+answered 404; the B6a interaction pass logged it as a console error on
+`/dashboard/journeys`. The middleware already skipped the path as if the file
+existed. `public/favicon.ico` now holds the brand icon at 16, 32 and 48 px
+(the existing PNGs, byte for byte). `tests/a-browser-asking-for-favicon-ico-gets-one.test.ts`
+(2 cases; fails with the file removed).
+
+**P-10 · High · No AI feature request was ever recorded, so the Free plan's
+AI allowance never counted the assistant.** Found by B6a: clicking through the
+signed-in pages logged `[ai/runs] failed to create the request` 17 times with
+`new row violates row-level security policy for table "ai_requests"`, and
+afterwards the local database (every migration applied) held 13 `concierge`
+rows and **no `feature` row at all**, although the crawl had used the
+assistant, the morning brief, the habit coach and the relationship helper.
+Migration `0255` lets a member insert an `ai_requests` row of kind
+`concierge` and no other ("feature, routine, trigger and handle_it requests
+are filed by server code"), but `createRequest` filed every kind on the
+caller's own client. `withAiRequest`, which every AI surface goes through,
+files `kind: 'feature'` — refused every time, logged, and carried on with no
+row. Two consequences: no assistant turn, brief or coach run left the record
+§33 promises; and `assertAIAccess` meters the Free plan's monthly AI requests
+by counting these rows (Pass L, L-03), so an assistant turn was never counted
+and the allowance could never be reached. `createRequest` now files a
+non-concierge request on the ledger client (`ledgerClient`, which the store
+already uses for every other ledger write), with the family and requester
+still taken from the verified scope; a concierge request stays on the
+member's client, where RLS checks it. No migration: the policy was right, the
+client was wrong. Production is at migration 0177 (PROD-DB-0177) and has none
+of these tables yet, so this takes effect when those migrations are applied.
+`tests/an-ai-feature-request-is-recorded-not-refused.test.ts` (5 cases; the
+feature case fails with the fix reverted); `tests/assistant-engine.test.ts`
+now routes the service client to its fake, so it still asserts the payload
+the turn asks for.
+**P-10, follow-up from review on #585: a filed row must not carry what was
+typed.** Once feature rows were recorded at all, `request_text` became
+readable by every active member of the family (0250's SELECT policy), and
+eight call sites handed `withAiRequest` a person's own words: the assistant's
+message (turn, stream and `/api/ai/chat`), the chef request, a marketplace
+question, a chore prompt, a post topic, a trip destination (and, milder, a
+chore title, an asset name, a trade). `AiRequestSpec.text` already said
+"keep it non-sensitive"; now every call site passes a fixed label ("Assistant
+turn", "Recipe from the AI chef", "Research a trip"), the model and the
+conversation still get the original text, and
+`tests/an-ai-request-row-never-carries-what-was-typed.test.ts` scans all 39
+call sites and fails on anything but a string literal or a catalogue string.
+`tests/assistant-engine.test.ts` passes a synthetic private phrase through the
+turn and the stream and asserts the model received it and the ledger row did
+not. No SQL change.
+
+**Not defects, recorded with the evidence.**
+- *502s on production.* 32 pages across both production crawls saw one
+ 502 on a prefetch, an API call or an asset — a different one each time,
+ never the document. Re-crawled one at a time at both widths: 61 of 64
+ loads clean, the other 3 each a 502 on a different prefetch. 60 consecutive prefetches through `curl`:
+ all 200 with `server: Vercel`. The crawler now records who answered a
+ 5xx, so the next run can tell a Vercel 502 from this sandbox's egress.
+- *Not-found under the app shell.* A missing id on a signed-in route shows
+ the correct not-found page with HTTP 200 and a React #419 in the console,
+ because `app/(app)/loading.tsx` has already streamed the shell when
+ `notFound()` throws. Next.js cannot change a status after streaming
+ starts; removing the loading shell would cost every signed-in page its
+ instant paint. Real ids render normally.
+- *`/capture` #418.* Seen once on the first crawl; 0 of 50 further loads
+ (production build and dev, Node 22 and 24) reproduced it. Watch.
+- *Node version.* The container's Node 22 produced stream-cancellation
+ server errors (and a #419 on a missing marketplace item) that Node 24.21
+ — the version `package.json` pins and production runs — does not. The
+ first local crawls ran on Node 22; every flag that could be Node-specific
+ (server stream errors, #419) was re-checked with the server on 24.21, and
+ the three unit tests that fail on Node 22 pass on 24.21.
+- *Missing tokens* (`/gift/…`, `/pay/…`, `/s/…`) answer a "this link is
+ not active" page with 200 and `noindex` — a deliberate soft answer for a
+ shared link, not a crawlable page.
+- *`/dashboard/knowledge/seed`* is the knowledge-base seeding tool and is
+ `notFound()` for everyone but a super administrator, by design; a family
+ parent sees the not-found page (with the #419 above).
+
+### Round 2 — the merged tree, after #588 and 339 other commits from main
+
+Re-crawled on `9c348703` (this branch merged with `main` at `19a24334`), local
+stack migrated to 0443, server on Node 24.21:
+
+| Pass | Pages | Flags | What they were |
+| --- | --- | --- | --- |
+| Family+ parent, every family route, 1280 px | 278 | 2 | `/ai` (P-08, fixed after this build) and `/resources/benchmarks` (404 by design) |
+| Family+ parent, every family route, 390 px (B10) | 278 | 2 | `/resources/benchmarks`; `/dashboard/knowledge/seed` super-admin-only (above) |
+| Super admin, every `/admin` route, 1280 + 390 px (B5, B10) | 78 × 2 | 1 × 2 | `/admin/social/providers` — the OAuth scope, above |
+| Id-based routes with seeded rows, 1280 + 390 px | 37 × 2 | 4 | all four are deliberately missing ids: the not-found page with #419, above |
+| Public pages in the ten other locales, production (B8, public half) | 41 × 10 | 12 | `/resources/benchmarks` × 10, and P-08 twice |
+| Teen in the Family+ household, every family route, 1280 px (B7) | 278 | 2 | `/resources/benchmarks` (404 by design); `/wallet/gift` once: a `ChunkLoadError` for a chunk the server does hold, caught by `app/global-error.tsx`. Not reproduced in 5 reloads, nor with that chunk request aborted on every load (the page stays server-rendered, no boundary); Next 15.5 turns a chunk failure during navigation into a hard navigation itself (`handleHardNavError`), and the boundary offers *Try again* and *Reload Bubaly*. Recorded as local-server load, not a defect |
+| Child in the Family+ household, every family route, 1280 px (B7) | 278 | 2 | `/resources/benchmarks`; `/acceptable-use` 503 from `/api/mkt/track` — P-08, which this build predates |
+
+Every family route has exactly one `` at both widths (P-06 holds), no
+route overflows at 390 px, and no page in any locale renders a raw
+catalogue key.
+
+Role views (B7): a teen and a child reach the same 277 routes a parent does, and
+only `/dashboard/family-access` sends them home. The pages that are role-aware
+render the member's view rather than redirecting, and the view is the right one
+where it was read side by side with the parent's: `/family/members` drops
+*Manage & invite*, `/dashboard/family-emergency` drops *Add contact*,
+`/dashboard/contact-center` drops *Assign*, and `/economy` shows "Ask a parent
+to set up your family's tokens" instead of the parent's *Manage* tab. Whether a
+teen should see `/dashboard/trust` and the wallet's activation page at all is a
+product question (both are read-only there, and every write behind them is
+refused by the manager checks recorded in the AUTHZ units); it is left for B6,
+which clicks the controls.
+
+### B6a — the interaction pass (every tab, dialog and button, clicked)
+
+`page-audit.mjs --interact` against the local stack, as the Family+ parent, on
+all 278 family routes: every tab, ``, and button inside `` (up
+to 40 a page), never a form submit and never a control labelled like a
+delete, payment, send or approval. **1,187 clicks; 268 routes clean; 10
+flagged, all explained:**
+
+| Route(s) | What the crawler saw | What it was |
+| --- | --- | --- |
+| `/dashboard/journeys` | 404 on `/favicon.ico` | **P-09**, fixed: the site had no `/favicon.ico` |
+| (server log, many pages) | `ai_requests` insert refused by RLS, 17× | **P-10**, fixed: no AI feature request was ever recorded |
+| `/home`, `/dashboard` (via `/auth/complete`, `/auth/step-up`, `/onboarding`), `/dashboard/assistant` | `[ai/requests] submit failed` / `[assistant] request failed: Failed to fetch` | the crawler's own reload aborting a request it had just started; the same click left alone answers 202 in 0.7 s |
+| `/dashboard/habits` | 400 from `/api/ai/habits` | the AI coach with no habits yet answers "add a habit first", shown as a toast |
+| `/dashboard/relationship` | 502 from `/api/ai/relationship` | the local stack's stub AI provider returns text the digest cannot parse; the route answers its "could not generate" message |
+| `/signup` | 400 + 404 after "Continue with Google" | Google sign-in is not configured on the local stack |
+| `/resources/benchmarks`, `/dashboard/knowledge/seed` | 404 | by design (unpublished; super-admin only), recorded above |
+
+Verified on a rebuild of `e52ec95a`: `/favicon.ico` answers 200
+`image/x-icon`; one message to the assistant containing a synthetic private
+phrase filed `feature | chat.assistant | Assistant chat | completed` in
+`ai_requests` (there had been no feature row before), no `ai_requests` row
+contains the phrase, and the member's own conversation holds it as sent.
+
+### Every route (first pass)
+
+`where` is what was crawled: `prod` against www.bubaly.com, `local` against
+the full local stack; widths in px. Dynamic routes list how many concrete
+pages were loaded. ✅ FIXED rows are clean on the local re-crawl of the fix
+and reach production with the PR that carries the commit named in the
+finding.
+
+| Route | Where | Status | Notes |
+| --- | --- | --- | --- |
+| `/` | local + prod 1280/390 | ✅ PASS | |
+| `/acceptable-use` | local + prod 1280/390 | ✅ PASS | |
+| `/admin` | local + prod 1280/390 | ✅ PASS | |
+| `/admin/admins` | local 1280/390 | ✅ FIXED | P-01 |
+| `/admin/ai` | local 1280 | ✅ PASS | |
+| `/admin/ai-activity` | local 1280 | ✅ PASS | |
+| `/admin/audit` | local 1280 | ✅ PASS | |
+| `/admin/audit-logs` | local 1280 | ✅ PASS | |
+| `/admin/backup` | local 1280 | ✅ PASS | |
+| `/admin/benchmarks` | local 1280 | ✅ PASS | |
+| `/admin/billing` | local 1280 | ✅ PASS | |
+| `/admin/content` | local 1280/390 | ✅ FIXED | P-01 |
+| `/admin/feedback` | local 1280 | ✅ PASS | |
+| `/admin/integrations` | local 1280 | ✅ PASS | |
+| `/admin/marketing` | local 1280 | ✅ PASS | |
+| `/admin/marketing/ads` | local 1280 | ✅ PASS | |
+| `/admin/marketing/aeo` | local 1280 | ✅ FIXED | P-06 |
+| `/admin/marketing/affiliates` | local 1280 | ✅ PASS | |
+| `/admin/marketing/analytics` | local 1280 | ✅ PASS | |
+| `/admin/marketing/assets` | local 1280 | ✅ PASS | |
+| `/admin/marketing/assistant` | local 1280 | ✅ PASS | |
+| `/admin/marketing/audit` | local 1280 | ✅ PASS | |
+| `/admin/marketing/automation` | local 1280 | ✅ PASS | |
+| `/admin/marketing/campaigns` | local 1280 | ✅ PASS | |
+| `/admin/marketing/campaigns/[id]` | local 1280 | ✅ FIXED | P-06 |
+| `/admin/marketing/campaigns/new` | local 1280 | ✅ PASS | |
+| `/admin/marketing/competitive` | local 1280 | ✅ PASS | |
+| `/admin/marketing/content` | local 1280/390 | ✅ FIXED | P-01 |
+| `/admin/marketing/crm` | local 1280 | ✅ PASS | |
+| `/admin/marketing/customers` | local 1280 | ✅ PASS | |
+| `/admin/marketing/email` | local 1280 | ✅ PASS | |
+| `/admin/marketing/exit-intent` | local 1280 | ✅ PASS | |
+| `/admin/marketing/experiments` | local 1280 | ✅ PASS | |
+| `/admin/marketing/forms` | local 1280 | ✅ PASS | |
+| `/admin/marketing/funnels` | local 1280 | ✅ PASS | |
+| `/admin/marketing/health` | local 1280 | ✅ PASS | |
+| `/admin/marketing/intelligence` | local 1280 | ✅ PASS | |
+| `/admin/marketing/landing-pages` | local 1280 | ✅ PASS | |
+| `/admin/marketing/lead-scores` | local 1280 | ✅ FIXED | P-06 |
+| `/admin/marketing/leads` | local 1280 | ✅ PASS | |
+| `/admin/marketing/loyalty` | local 1280 | ✅ PASS | |
+| `/admin/marketing/personalization` | local 1280 | ✅ PASS | |
+| `/admin/marketing/pipeline` | local 1280 | ✅ PASS | |
+| `/admin/marketing/platform` | local 1280 | ✅ PASS | |
+| `/admin/marketing/proposals` | local 1280 | ✅ PASS | |
+| `/admin/marketing/push` | local 1280 | ✅ PASS | |
+| `/admin/marketing/referrals` | local 1280 | ✅ PASS | |
+| `/admin/marketing/reputation` | local 1280 | ✅ PASS | |
+| `/admin/marketing/reviews` | local 1280 | ✅ PASS | |
+| `/admin/marketing/segments` | local 1280 | ✅ PASS | |
+| `/admin/marketing/seo` | local 1280 | ✅ FIXED | P-06 |
+| `/admin/marketing/settings` | local 1280 | ✅ PASS | |
+| `/admin/marketing/sms` | local 1280 | ✅ PASS | |
+| `/admin/marketing/social` | local 1280 | ✅ PASS | |
+| `/admin/marketing/social/recurring` | local 1280 | ✅ PASS | |
+| `/admin/marketing/surveys` | local 1280 | ✅ PASS | |
+| `/admin/marketing/surveys/[id]` | local 1280 | ✅ PASS | |
+| `/admin/marketing/video` | local 1280 | ✅ PASS | |
+| `/admin/marketing/visitor-intelligence` | local 1280 | ✅ FIXED | P-06 |
+| `/admin/marketplace/reports` | local 1280 | ✅ PASS | |
+| `/admin/notifications` | local 1280 | ✅ PASS | |
+| `/admin/onboarding` | local 1280 | ✅ PASS | |
+| `/admin/reports` | local 1280 | ✅ PASS | |
+| `/admin/security` | local 1280 | ✅ PASS | |
+| `/admin/services` | local 1280 | ✅ PASS | |
+| `/admin/settings` | local 1280 | ✅ PASS | |
+| `/admin/settings/social-links` | local 1280 | ✅ PASS | |
+| `/admin/social` | local 1280 | ✅ PASS | |
+| `/admin/social/audit` | local 1280 | ✅ PASS | |
+| `/admin/social/providers` | local 1280 | ✅ PASS | "offline.access" flagged as an i18n key is X's real OAuth scope, listed on purpose |
+| `/admin/social/usage` | local 1280 | ✅ PASS | |
+| `/admin/stripe` | local 1280 | ✅ PASS | |
+| `/admin/subscriptions` | local 1280/390 | ✅ PASS | |
+| `/admin/support` | local 1280 | ✅ PASS | |
+| `/admin/support-tickets` | local 1280/390 | ✅ FIXED | P-01 |
+| `/admin/sync` | local 1280 | ✅ PASS | |
+| `/admin/system` | local 1280 | ✅ PASS | |
+| `/admin/tier-features` | local 1280 | ✅ PASS | |
+| `/admin/tiers` | local 1280 | ✅ PASS | |
+| `/admin/users` | local 1280/390 | ✅ FIXED | P-01 |
+| `/admin/wallet` | local 1280 | ✅ PASS | |
+| `/admin/wallet/reconciliation` | local 1280 | ✅ PASS | |
+| `/ai` | local + prod 1280/390 | ✅ FIXED | P-08 (a 503 from /api/mkt/track, first-visit race; production, answered by Vercel) |
+| `/alternatives/[slug]` | prod 1280/390 | ✅ PASS | an unknown slug/id answers a real 404 page |
+| `/audiences/[slug]` | prod 1280/390 | ✅ PASS | an unknown slug/id answers a real 404 page |
+| `/auth/complete` | local + prod 1280/390 | ✅ PASS | |
+| `/auth/recovery` | local + prod 1280/390 | ✅ PASS | |
+| `/auth/signout/complete` | local + prod 1280/390 | ✅ PASS | |
+| `/auth/step-up` | local 1280 | ✅ PASS | |
+| `/blog` | local + prod 1280/390 | ✅ PASS | |
+| `/blog/[slug]` | prod 1280/390 | ✅ FIXED | 1049 pages; P-02; a one-off 502 on a prefetch/asset, clean on re-check one at a time (see *502s on production* above); an unknown slug/id answers a real 404 page |
+| `/capture` | local 1280/390 | ✅ PASS | |
+| `/capture/link` | local 1280 | ✅ PASS | |
+| `/compare/[slug]` | prod 1280/390 | ✅ PASS | an unknown slug/id answers a real 404 page |
+| `/contact` | local + prod 1280/390 | ✅ PASS | |
+| `/cookies` | local + prod 1280/390 | ✅ PASS | |
+| `/customers/[slug]` | prod 1280/390 | ✅ PASS | an unknown slug/id answers a real 404 page |
+| `/dashboard` | local + prod 1280/390 | ✅ PASS | |
+| `/dashboard/activity` | local 1280 | ✅ PASS | |
+| `/dashboard/agents` | local 1280 | ✅ PASS | |
+| `/dashboard/announcements` | local 1280 | ✅ PASS | |
+| `/dashboard/app-store` | local 1280 | ✅ PASS | |
+| `/dashboard/assistant` | local 1280 | ✅ FIXED | P-06 |
+| `/dashboard/assistant/purchases/[approvalId]` | local 1280 | ✅ PASS | missing id → the not-found page under the (app) loading shell (HTTP 200 + React #419, Next streaming); real ids pass |
+| `/dashboard/assistants` | local 1280 | ✅ PASS | |
+| `/dashboard/auto` | local 1280 | ✅ PASS | |
+| `/dashboard/auto/accident` | local 1280 | ✅ PASS | |
+| `/dashboard/auto/insurance` | local 1280 | ✅ PASS | |
+| `/dashboard/auto/licenses` | local 1280 | ✅ PASS | |
+| `/dashboard/auto/registration` | local 1280 | ✅ PASS | |
+| `/dashboard/auto/rentals` | local 1280 | ✅ PASS | |
+| `/dashboard/auto/service` | local 1280 | ✅ PASS | |
+| `/dashboard/auto/vehicles` | local 1280 | ✅ PASS | |
+| `/dashboard/autonomous-family-management` | local 1280 | ✅ PASS | |
+| `/dashboard/autopay` | local 1280 | ✅ PASS | |
+| `/dashboard/autopilot` | local 1280 | ✅ FIXED | P-05 |
+| `/dashboard/behavior` | local 1280 | ✅ FIXED | P-06 |
+| `/dashboard/billing` | local 1280 | ✅ PASS | |
+| `/dashboard/bills` | local 1280 | ✅ PASS | |
+| `/dashboard/binder` | local 1280 | ✅ FIXED | P-06, P-07 |
+| `/dashboard/briefing` | local 1280 | ✅ FIXED | P-05 |
+| `/dashboard/budgets` | local 1280 | ✅ PASS | |
+| `/dashboard/calendar` | local 1280/390 | ✅ FIXED | P-03 |
+| `/dashboard/calm` | local 1280 | ✅ PASS | |
+| `/dashboard/care` | local 1280 | ✅ PASS | |
+| `/dashboard/career` | local 1280 | ✅ PASS | |
+| `/dashboard/celebrations` | local 1280 | ✅ PASS | |
+| `/dashboard/chores` | local 1280 | ✅ PASS | |
+| `/dashboard/closet` | local 1280 | ✅ PASS | |
+| `/dashboard/command-center` | local 1280 | ✅ PASS | |
+| `/dashboard/concierge` | local 1280 | ✅ PASS | |
+| `/dashboard/concierge-calls` | local 1280 | ✅ PASS | |
+| `/dashboard/concierge/runs` | local 1280 | ✅ PASS | |
+| `/dashboard/concierge/runs/[id]` | local 1280 | ✅ PASS | |
+| `/dashboard/conflicts` | local 1280 | ✅ PASS | |
+| `/dashboard/connections` | local 1280 | ✅ PASS | |
+| `/dashboard/contact-center` | local 1280 | ✅ PASS | |
+| `/dashboard/contacts` | local 1280 | ✅ PASS | |
+| `/dashboard/contacts/[id]` | local 1280 | ✅ PASS | 2 pages |
+| `/dashboard/decisions` | local 1280 | ✅ PASS | |
+| `/dashboard/declutter` | local 1280 | ✅ PASS | |
+| `/dashboard/dental` | local 1280 | ✅ PASS | |
+| `/dashboard/devices` | local 1280 | ✅ FIXED | P-06 |
+| `/dashboard/dining` | local 1280 | ✅ PASS | |
+| `/dashboard/documents` | local 1280 | ✅ PASS | |
+| `/dashboard/due` | local 1280 | ✅ PASS | |
+| `/dashboard/expenses` | local 1280 | ✅ FIXED | P-06 |
+| `/dashboard/experience` | local 1280 | ✅ PASS | |
+| `/dashboard/family` | local 1280 | ✅ PASS | |
+| `/dashboard/family-access` | local 1280 | ✅ PASS | |
+| `/dashboard/family-ai-assistant` | local 1280 | ✅ FIXED | P-06 |
+| `/dashboard/family-automation` | local 1280 | ✅ PASS | |
+| `/dashboard/family-cfo` | local 1280 | ✅ PASS | |
+| `/dashboard/family-coo` | local 1280 | ✅ PASS | |
+| `/dashboard/family-digital-twin` | local 1280 | ✅ PASS | |
+| `/dashboard/family-emergency` | local 1280 | ✅ PASS | |
+| `/dashboard/family-health` | local 1280 | ✅ PASS | |
+| `/dashboard/family-knowledge-graph` | local 1280 | ✅ PASS | |
+| `/dashboard/family-memory` | local 1280 | ✅ PASS | |
+| `/dashboard/family-operating-index` | local 1280 | ✅ PASS | |
+| `/dashboard/family-operations` | local 1280 | ✅ PASS | |
+| `/dashboard/family-school` | local 1280 | ✅ PASS | |
+| `/dashboard/family-signals` | local 1280 | ✅ PASS | |
+| `/dashboard/family-sports` | local 1280 | ✅ PASS | |
+| `/dashboard/family-stress` | local 1280 | ✅ FIXED | P-07 |
+| `/dashboard/family-tree` | local 1280 | ✅ PASS | |
+| `/dashboard/family/check-in` | local 1280 | ✅ PASS | |
+| `/dashboard/family/driving-safety` | local 1280 | ✅ PASS | |
+| `/dashboard/family/find-phone` | local 1280 | ✅ PASS | |
+| `/dashboard/family/play-dates` | local 1280 | ✅ PASS | |
+| `/dashboard/favorites` | local 1280 | ✅ PASS | |
+| `/dashboard/files/cloud` | local 1280 | ✅ PASS | |
+| `/dashboard/files/shared` | local 1280 | ✅ PASS | |
+| `/dashboard/files/vault` | local 1280 | ✅ PASS | |
+| `/dashboard/focus` | local 1280 | ✅ PASS | |
+| `/dashboard/food` | local 1280 | ✅ PASS | |
+| `/dashboard/fridge-chef` | local 1280 | ✅ PASS | |
+| `/dashboard/front-desk` | local 1280 | ✅ PASS | |
+| `/dashboard/goals` | local 1280 | ✅ PASS | |
+| `/dashboard/grandparent-portal` | local 1280 | ✅ PASS | |
+| `/dashboard/graph` | local 1280 | ✅ PASS | |
+| `/dashboard/grocery` | local 1280 | ✅ FIXED | P-06 |
+| `/dashboard/habits` | local 1280 | ✅ PASS | |
+| `/dashboard/health` | local 1280 | ✅ PASS | |
+| `/dashboard/home` | local 1280 | ✅ PASS | |
+| `/dashboard/home/assets/[id]` | local 1280 | ✅ PASS | |
+| `/dashboard/home/diagnose` | local 1280 | ✅ PASS | |
+| `/dashboard/home/maintenance` | local 1280 | ✅ PASS | |
+| `/dashboard/home/pros` | local 1280 | ✅ PASS | |
+| `/dashboard/home/service` | local 1280 | ✅ PASS | |
+| `/dashboard/home/warranties` | local 1280 | ✅ PASS | |
+| `/dashboard/homework` | local 1280 | ✅ PASS | |
+| `/dashboard/inbox` | local 1280 | ✅ PASS | |
+| `/dashboard/independence` | local 1280 | ✅ PASS | |
+| `/dashboard/insurance` | local 1280 | ✅ PASS | |
+| `/dashboard/intelligence` | local 1280 | ✅ PASS | |
+| `/dashboard/inventory` | local 1280 | ✅ PASS | |
+| `/dashboard/journal` | local 1280 | ✅ PASS | |
+| `/dashboard/journeys` | local 1280 | ✅ PASS | |
+| `/dashboard/kitchen` | local 1280 | ✅ PASS | |
+| `/dashboard/knowledge` | local 1280 | ✅ PASS | |
+| `/dashboard/knowledge/seed` | local 1280 | ✅ PASS | |
+| `/dashboard/language` | local 1280 | ✅ PASS | |
+| `/dashboard/library` | local 1280 | ✅ PASS | |
+| `/dashboard/life-events` | local 1280 | ✅ PASS | |
+| `/dashboard/locator` | local 1280 | ✅ PASS | |
+| `/dashboard/meals` | local 1280 | ✅ PASS | |
+| `/dashboard/medical` | local 1280 | ✅ PASS | |
+| `/dashboard/medications` | local 1280 | ✅ PASS | |
+| `/dashboard/memories` | local 1280 | ✅ PASS | |
+| `/dashboard/memories/create` | local 1280 | ✅ PASS | |
+| `/dashboard/messages` | local 1280 | ✅ PASS | |
+| `/dashboard/migrate` | local 1280 | ✅ PASS | |
+| `/dashboard/moments` | local 1280 | ✅ PASS | |
+| `/dashboard/money-timeline` | local 1280 | ✅ PASS | |
+| `/dashboard/more` | local 1280 | ✅ PASS | |
+| `/dashboard/moving` | local 1280 | ✅ PASS | |
+| `/dashboard/needs-you` | local 1280 | ✅ PASS | |
+| `/dashboard/next-best-actions` | local 1280 | ✅ PASS | |
+| `/dashboard/notes` | local 1280 | ✅ PASS | |
+| `/dashboard/notifications` | local 1280 | ✅ PASS | |
+| `/dashboard/nutrition` | local 1280 | ✅ PASS | |
+| `/dashboard/onboarding-funnel` | local 1280 | ✅ PASS | |
+| `/dashboard/outcomes` | local 1280 | ✅ PASS | |
+| `/dashboard/pantry` | local 1280 | ✅ PASS | |
+| `/dashboard/paperwork` | local 1280 | ✅ PASS | |
+| `/dashboard/passwords` | local 1280 | ✅ PASS | |
+| `/dashboard/payments` | local 1280 | ✅ PASS | |
+| `/dashboard/pets` | local 1280 | ✅ PASS | |
+| `/dashboard/photos` | local 1280 | ✅ PASS | |
+| `/dashboard/planning` | local 1280 | ✅ PASS | |
+| `/dashboard/playbook` | local 1280 | ✅ PASS | |
+| `/dashboard/prep-plans` | local 1280 | ✅ PASS | |
+| `/dashboard/profile` | local 1280 | ✅ PASS | |
+| `/dashboard/projects` | local 1280 | ✅ PASS | |
+| `/dashboard/readiness` | local 1280 | ✅ PASS | |
+| `/dashboard/reasoning` | local 1280 | ✅ PASS | |
+| `/dashboard/recipes` | local 1280 | ✅ PASS | |
+| `/dashboard/recipes/discover` | local 1280 | ✅ PASS | |
+| `/dashboard/recipes/vote` | local 1280 | ✅ PASS | |
+| `/dashboard/relationship` | local 1280 | ✅ PASS | |
+| `/dashboard/reminders` | local 1280 | ✅ PASS | |
+| `/dashboard/renewals` | local 1280 | ✅ PASS | |
+| `/dashboard/rewards` | local 1280 | ✅ PASS | |
+| `/dashboard/rides` | local 1280 | ✅ PASS | |
+| `/dashboard/savings` | local 1280 | ✅ PASS | |
+| `/dashboard/scan` | local 1280 | ✅ PASS | |
+| `/dashboard/school` | local 1280 | ✅ PASS | |
+| `/dashboard/screen-time` | local 1280 | ✅ FIXED | P-06 |
+| `/dashboard/search` | local 1280 | ✅ PASS | |
+| `/dashboard/security` | local 1280 | ✅ FIXED | P-06 |
+| `/dashboard/settings` | local 1280 | ✅ PASS | |
+| `/dashboard/setup` | local 1280 | ✅ PASS | |
+| `/dashboard/signups` | local 1280 | ✅ PASS | |
+| `/dashboard/sleep` | local 1280 | ✅ PASS | |
+| `/dashboard/social` | local 1280 | ✅ PASS | |
+| `/dashboard/social-feed` | local 1280 | ✅ PASS | |
+| `/dashboard/social/accounts` | local 1280 | ✅ PASS | |
+| `/dashboard/social/accounts/connect` | local 1280 | ✅ PASS | |
+| `/dashboard/social/analytics` | local 1280 | ✅ PASS | |
+| `/dashboard/social/calendar` | local 1280 | ✅ PASS | |
+| `/dashboard/social/content-studio` | local 1280 | ✅ PASS | |
+| `/dashboard/social/content-studio/new` | local 1280 | ✅ PASS | |
+| `/dashboard/social/failed` | local 1280 | ✅ PASS | |
+| `/dashboard/social/feed` | local 1280 | ✅ PASS | |
+| `/dashboard/social/inbox` | local 1280 | ✅ PASS | |
+| `/dashboard/social/media-library` | local 1280 | ✅ PASS | |
+| `/dashboard/social/posts` | local 1280 | ✅ PASS | |
+| `/dashboard/social/posts/[id]` | local 1280 | ✅ PASS | |
+| `/dashboard/social/published` | local 1280 | ✅ PASS | |
+| `/dashboard/social/scheduled` | local 1280 | ✅ PASS | |
+| `/dashboard/social/settings` | local 1280 | ✅ PASS | |
+| `/dashboard/sports` | local 1280 | ✅ PASS | |
+| `/dashboard/subscriptions` | local 1280 | ✅ FIXED | P-06 |
+| `/dashboard/sync` | local 1280 | ✅ PASS | |
+| `/dashboard/sync/accounts` | local 1280 | ✅ PASS | |
+| `/dashboard/sync/accounts/[provider]` | local 1280 | ✅ PASS | 2 pages |
+| `/dashboard/sync/conflicts` | local 1280 | ✅ PASS | |
+| `/dashboard/sync/history` | local 1280 | ✅ PASS | |
+| `/dashboard/tax-vault` | local 1280 | ✅ FIXED | P-06 |
+| `/dashboard/timetable` | local 1280 | ✅ PASS | |
+| `/dashboard/todos` | local 1280 | ✅ PASS | |
+| `/dashboard/trip-intel` | local 1280 | ✅ PASS | |
+| `/dashboard/trip-memories` | local 1280 | ✅ FIXED | P-06 |
+| `/dashboard/trips` | local 1280 | ✅ PASS | |
+| `/dashboard/trust` | local 1280 | ✅ PASS | |
+| `/dashboard/utilities` | local 1280 | ✅ FIXED | P-06 |
+| `/dashboard/vacations` | local 1280 | ✅ PASS | |
+| `/dashboard/vacations/[id]` | local 1280 | ✅ FIXED | 2 pages; P-07 |
+| `/dashboard/vacations/[id]/activities` | local 1280 | ✅ PASS | |
+| `/dashboard/vacations/[id]/ai-assistant` | local 1280 | ✅ PASS | |
+| `/dashboard/vacations/[id]/budget` | local 1280 | ✅ PASS | |
+| `/dashboard/vacations/[id]/documents` | local 1280 | ✅ PASS | |
+| `/dashboard/vacations/[id]/emergency` | local 1280 | ✅ PASS | |
+| `/dashboard/vacations/[id]/family` | local 1280 | ✅ PASS | |
+| `/dashboard/vacations/[id]/itinerary` | local 1280 | ✅ PASS | |
+| `/dashboard/vacations/[id]/lodging` | local 1280 | ✅ PASS | |
+| `/dashboard/vacations/[id]/overview` | local 1280 | ✅ PASS | |
+| `/dashboard/vacations/[id]/packing` | local 1280 | ✅ PASS | |
+| `/dashboard/vacations/[id]/travel` | local 1280 | ✅ PASS | |
+| `/dashboard/vacations/[id]/weather` | local 1280 | ✅ PASS | |
+| `/dashboard/vacations/calendar` | local 1280 | ✅ PASS | |
+| `/dashboard/vacations/new` | local 1280/390 | ✅ FIXED | P-04 |
+| `/dashboard/vacations/reports` | local 1280 | ✅ FIXED | P-06 |
+| `/dashboard/voice` | local 1280 | ✅ PASS | |
+| `/dashboard/voting` | local 1280 | ✅ FIXED | P-06 |
+| `/dashboard/watchlist` | local 1280 | ✅ PASS | |
+| `/dashboard/weather` | local 1280 | ✅ PASS | |
+| `/dashboard/weekend` | local 1280 | ✅ PASS | |
+| `/dashboard/weekly-briefing` | local 1280 | ✅ PASS | |
+| `/dashboard/wishlists` | local 1280 | ✅ PASS | |
+| `/dashboard/workload` | local 1280 | ✅ PASS | |
+| `/display` | local 1280 | ✅ PASS | |
+| `/display/setup` | local 1280 | ✅ PASS | |
+| `/economy` | local 1280 | ✅ PASS | |
+| `/f/[id]` | local + prod 1280/390 | ✅ FIXED | 2 pages; P-06; an unknown slug/id answers a real 404 page |
+| `/family-display` | local + prod 1280/390 | ✅ PASS | |
+| `/family/activity` | local 1280 | ✅ PASS | |
+| `/family/members` | local 1280 | ✅ PASS | |
+| `/family/notifications` | local 1280 | ✅ PASS | |
+| `/family/permissions` | local 1280 | ✅ PASS | |
+| `/family/reports` | local 1280 | ✅ PASS | |
+| `/family/settings` | local 1280 | ✅ PASS | |
+| `/faq` | local + prod 1280/390 | ✅ PASS | |
+| `/features` | local + prod 1280/390 | ✅ PASS | |
+| `/features/[slug]` | prod 1280/390 | ✅ PASS | an unknown slug/id answers a real 404 page |
+| `/feedback` | local 1280 | ✅ PASS | |
+| `/gift/[token]` | local + prod 1280/390 | ✅ PASS | 2 pages |
+| `/glossary/[slug]` | prod 1280/390 | ✅ PASS | an unknown slug/id answers a real 404 page |
+| `/guardian` | local 1280 | ✅ PASS | |
+| `/guardian/contacts` | local 1280 | ✅ PASS | |
+| `/guardian/history` | local 1280 | ✅ PASS | |
+| `/guardian/rules` | local 1280 | ✅ PASS | |
+| `/guardian/settings` | local 1280 | ✅ PASS | |
+| `/guides/[slug]` | prod 1280/390 | ✅ PASS | an unknown slug/id answers a real 404 page |
+| `/home` | local + prod 1280/390 | ✅ PASS | |
+| `/how-it-works` | local + prod 1280/390 | ✅ PASS | |
+| `/join` | local + prod 1280/390 | ✅ PASS | |
+| `/kid-login` | local + prod 1280/390 | ✅ PASS | a one-off 502 on a prefetch/asset, clean on re-check one at a time (see *502s on production* above) |
+| `/kids` | local 1280 | ✅ PASS | |
+| `/kids/submit/[assignmentId]` | local 1280 | ✅ PASS | |
+| `/login` | local + prod 1280/390 | ✅ PASS | |
+| `/lp/[slug]` | prod 1280/390 | ✅ PASS | an unknown slug/id answers a real 404 page |
+| `/marketplace` | local 1280 | ✅ PASS | |
+| `/marketplace/alerts` | local 1280 | ✅ PASS | |
+| `/marketplace/auctions` | local 1280 | ✅ PASS | |
+| `/marketplace/browse` | local 1280 | ✅ PASS | |
+| `/marketplace/collections` | local 1280 | ✅ PASS | |
+| `/marketplace/community` | local 1280 | ✅ PASS | |
+| `/marketplace/creators` | local 1280 | ✅ PASS | |
+| `/marketplace/creators/[id]` | local 1280 | ✅ PASS | |
+| `/marketplace/deals` | local 1280 | ✅ PASS | |
+| `/marketplace/following` | local 1280 | ✅ PASS | |
+| `/marketplace/insights` | local 1280 | ✅ PASS | |
+| `/marketplace/item/[id]` | local 1280 | ✅ PASS | 2 pages; missing id → the not-found page under the (app) loading shell (HTTP 200 + React #419, Next streaming); real ids pass |
+| `/marketplace/negotiations` | local 1280 | ✅ PASS | |
+| `/marketplace/orders` | local 1280 | ✅ PASS | |
+| `/marketplace/questions` | local 1280 | ✅ PASS | |
+| `/marketplace/reviews` | local 1280 | ✅ PASS | |
+| `/marketplace/saved` | local 1280 | ✅ PASS | |
+| `/marketplace/seed` | local 1280 | ✅ PASS | |
+| `/marketplace/selling` | local 1280 | ✅ PASS | |
+| `/marketplace/store` | local 1280 | ✅ PASS | |
+| `/missions` | local 1280 | ✅ PASS | |
+| `/missions/new` | local 1280 | ✅ FIXED | P-06 |
+| `/mobile` | local + prod 1280/390 | ✅ FIXED | P-08 (a 503 from /api/mkt/track, first-visit race; production, answered by Vercel) |
+| `/offline` | local + prod 1280/390 | ✅ PASS | |
+| `/onboarding` | local + prod 1280/390 | ✅ PASS | |
+| `/p/[slug]` | prod 1280/390 | ✅ PASS | an unknown slug/id answers a real 404 page |
+| `/parent` | local 1280 | ✅ PASS | |
+| `/pay/[handle]` | local + prod 1280/390 | ✅ PASS | 2 pages |
+| `/pricing` | local + prod 1280/390 | ✅ PASS | |
+| `/privacy` | local + prod 1280/390 | ✅ PASS | |
+| `/questions/[slug]` | prod 1280/390 | ✅ PASS | an unknown slug/id answers a real 404 page |
+| `/referrals` | local 1280 | ✅ PASS | |
+| `/resources/[slug]` | prod 1280/390 | ✅ PASS | an unknown slug/id answers a real 404 page |
+| `/resources/benchmarks` | local + prod 1280/390 | ✅ PASS | 404 by design until an operator publishes the benchmarks |
+| `/reviews` | local + prod 1280/390 | ✅ PASS | |
+| `/reviews/new` | local + prod 1280/390 | ✅ PASS | |
+| `/s/[slug]` | local + prod 1280/390 | ✅ PASS | 2 pages |
+| `/security` | local + prod 1280/390 | ✅ PASS | |
+| `/services` | local 1280 | ✅ PASS | |
+| `/services/[category]` | local 1280 | ✅ PASS | 3 pages |
+| `/signup` | local + prod 1280/390 | ✅ PASS | |
+| `/terms` | local + prod 1280/390 | ✅ PASS | |
+| (any unknown URL, e.g. `/this-page-does-not-exist`) | prod 1280/390 | ✅ PASS | the site 404 page, HTTP 404, one `` |
+| `/wallet` | local 1280 | ✅ PASS | |
+| `/wallet/activity` | local 1280 | ✅ PASS | |
+| `/wallet/allowance` | local 1280 | ✅ PASS | |
+| `/wallet/babysitters` | local 1280 | ✅ PASS | |
+| `/wallet/cards` | local 1280 | ✅ PASS | |
+| `/wallet/children/[childId]` | local 1280 | ✅ PASS | 2 pages; missing id → the not-found page under the (app) loading shell (HTTP 200 + React #419, Next streaming); real ids pass |
+| `/wallet/gift` | local 1280 | ✅ PASS | |
+| `/wallet/goals` | local 1280 | ✅ PASS | |
+| `/wallet/invest` | local 1280 | ✅ PASS | |
+| `/wallet/send` | local 1280 | ✅ PASS | |
+| `/wallet/settings` | local 1280 | ✅ PASS | |
+| `/wallet/treasury` | local 1280 | ✅ PASS | |
+| `/welcome` | local + prod 1280/390 | ✅ PASS | a one-off 502 on a prefetch/asset, clean on re-check one at a time (see *502s on production* above) |
diff --git a/lib/ai/assistant-engine.ts b/lib/ai/assistant-engine.ts
index b8433731b..527953e36 100644
--- a/lib/ai/assistant-engine.ts
+++ b/lib/ai/assistant-engine.ts
@@ -455,7 +455,7 @@ export async function runAssistantTurn(input: AssistantTurnInput, prepared: Prep
// `createRequest` silently coerces an unknown one to 'concierge' — which
// would file every assistant turn under the concierge planner, a different
// surface entirely. The `feature` string is what names this one.
- { feature: 'assistant.turn', text: input.message, conversationId: input.conversationId },
+ { feature: 'assistant.turn', text: 'Assistant turn', conversationId: input.conversationId },
async (obs) => {
const result = await provider.runTools({ system, messages, tools, maxTokens: ASSISTANT_MAX_TOKENS });
obs.used(provider.model, result.usage);
@@ -503,7 +503,7 @@ export function createAssistantStream(input: AssistantTurnInput, prepared: Prepa
// solves it the same way.
await withAiRequest(
prepared.scope,
- { feature: 'assistant.stream', text: input.message, conversationId: input.conversationId },
+ { feature: 'assistant.stream', text: 'Assistant stream', conversationId: input.conversationId },
async (obs) => {
const send = (e: unknown) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(e)}\n\n`));
let content = '';
diff --git a/lib/ai/observability.ts b/lib/ai/observability.ts
index 34e54a77a..8631d9dbb 100644
--- a/lib/ai/observability.ts
+++ b/lib/ai/observability.ts
@@ -36,10 +36,13 @@ export type AiRequestSpec = {
/** Dotted surface name, e.g. `briefing.daily`, `chat.assistant`. Stored on the row. */
feature: string;
/**
- * What the family asked for. Keep it non-sensitive — most surfaces pass a
- * fixed label ("Analyse a note") precisely so a person's own words never land
- * here. Truncated to `MAX_AI_REQUEST_TEXT_CHARS` on the way in, so a caller
- * interpolating unbounded input cannot write an unbounded row.
+ * A fixed label for what was asked ("Analyse a note", "Assistant turn") —
+ * never the person's own words or anything derived from them. The row is
+ * readable by every active member of the family (0250's SELECT policy), so a
+ * message typed to the assistant, a pasted note or a trip destination written
+ * here would be shown to the rest of the household. Every call site passes a
+ * string literal; `tests/an-ai-request-row-never-carries-what-was-typed.test.ts`
+ * holds them to it. Still truncated to `MAX_AI_REQUEST_TEXT_CHARS`.
*/
text: string;
kind?: AiRequestKind;
diff --git a/lib/ai/runs/store.ts b/lib/ai/runs/store.ts
index fcae09608..5303378c0 100644
--- a/lib/ai/runs/store.ts
+++ b/lib/ai/runs/store.ts
@@ -105,7 +105,16 @@ export async function createRequest(
if (!text) return fail('Tell Bubaly what you need.', { code: SERVICE_CODES.invalidInput });
const kind = (REQUEST_KINDS as readonly string[]).includes(input.kind) ? (input.kind as AiRequestKind) : 'concierge';
- const db = opts?.db ?? scope.db;
+ // Only a concierge request is the person's own filing, checked by RLS on
+ // their client. 0255 lets a member insert that kind and no other ("feature,
+ // routine, trigger and handle_it requests are filed by server code"), so
+ // filing a `feature` row — every surface behind `withAiRequest` — on the
+ // member's client was refused, every time: no assistant turn, brief or coach
+ // run was ever recorded, and the Free plan's monthly allowance, which counts
+ // these rows, never counted them (2026-09-27 page audit, P-10). Server code
+ // files the other kinds on the ledger client, with the family and requester
+ // taken from the verified scope as above.
+ const db = opts?.db ?? (kind === 'concierge' ? scope.db : ledgerClient(scope, opts));
const { data, error } = await db
.from('ai_requests')
diff --git a/lib/chores/ai.ts b/lib/chores/ai.ts
index 9d1d610ce..2e51a42d3 100644
--- a/lib/chores/ai.ts
+++ b/lib/chores/ai.ts
@@ -108,7 +108,7 @@ export async function validateChoreSubmission(scope: ServiceScope, input: Valida
scope,
// The chore title, not the child's note: a kid's own words about what they
// did are not something the request ledger needs to carry.
- { feature: 'chores.validate', text: `Validate a chore submission: ${input.choreTitle}` },
+ { feature: 'chores.validate', text: 'Validate a chore submission' },
async (obs) => {
const provider = getProvider();
const completion = await provider.complete({
@@ -167,7 +167,7 @@ export async function generateChorePlan(
try {
return await withAiRequest(
scope,
- { feature: 'chores.plan', text: prompt.slice(0, 200) },
+ { feature: 'chores.plan', text: 'Plan chores' },
async (obs) => {
const provider = getProvider();
const completion = await provider.complete({ system: PLAN_SYSTEM, messages: [{ role: 'user', content: userText }], tools: [] });
diff --git a/lib/server/route-feature-gate.ts b/lib/server/route-feature-gate.ts
index fb5a7d520..4acd2e2d4 100644
--- a/lib/server/route-feature-gate.ts
+++ b/lib/server/route-feature-gate.ts
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@/lib/database.types';
import { resolveFeatureEntitlement } from '@/lib/server/feature-entitlement';
+import { isSuperAdmin } from '@/lib/supabase/auth';
type DB = SupabaseClient;
@@ -48,6 +49,17 @@ export async function refuseUnlessEntitled(
}
}
+ // The page in front of this endpoint lets a super administrator through
+ // (`requireFeature` and `requirePlanLevel` both do, to preview and support).
+ // Refusing them here left those pages half-broken for the one account that
+ // can open them regardless of plan: /dashboard/autopilot and
+ // /dashboard/briefing rendered and then got 403 from their own endpoints
+ // (2026-09-27 page audit). Asked only once the family itself was refused,
+ // and a lookup that fails is "no" — never a reason to let a request through.
+ // Asked of `db` — the client this endpoint authenticated (a bearer token on
+ // /api/ai), never a separate cookie session the same browser may carry.
+ if (await callerIsSuperAdmin(db)) return null;
+
if (outcomes.every((o) => o.reason === 'off')) {
return NextResponse.json({ error: 'Not found.', code: 'feature_off' }, { status: 404 });
}
@@ -64,3 +76,12 @@ export async function refuseUnlessEntitled(
{ status: 403 },
);
}
+
+async function callerIsSuperAdmin(db: DB): Promise {
+ try {
+ return await isSuperAdmin(db);
+ } catch (error) {
+ console.error('[route-feature-gate] super-admin check failed; applying the family gate', error);
+ return false;
+ }
+}
diff --git a/lib/social/ai.ts b/lib/social/ai.ts
index 7d67fd5b0..bcfaad4b8 100644
--- a/lib/social/ai.ts
+++ b/lib/social/ai.ts
@@ -86,7 +86,7 @@ export async function generate(scope: ServiceScope, input: AiGenerateInput): Pro
// `social_ai_logs` error row and answers honestly.
return withAiRequest(
scope,
- { feature: `social.${input.kind}`, text: (input.topic ?? input.kind).slice(0, 200) },
+ { feature: `social.${input.kind}`, text: 'Draft a social post' },
async (obs) => {
const provider = await resolveProvider();
const completion = await provider.complete({
diff --git a/lib/supabase/auth.ts b/lib/supabase/auth.ts
index 724928188..f988c45d8 100644
--- a/lib/supabase/auth.ts
+++ b/lib/supabase/auth.ts
@@ -7,7 +7,8 @@ import { resolveFeatureEntitlement } from '@/lib/server/feature-entitlement';
import { ensureActiveFamily } from '@/lib/server/ensure-family';
import { isRetryableAuthError } from '@/lib/auth/session';
import type { MemberRole } from '@/lib/constants/roles';
-import type { Tables } from '@/lib/database.types';
+import type { SupabaseClient } from '@supabase/supabase-js';
+import type { Database, Tables } from '@/lib/database.types';
export type FamilyMembership = {
familyId: string;
@@ -73,8 +74,13 @@ export async function getUser() {
* super_admins allowlist, independent of family membership). Unlike family roles,
* this grants oversight of the whole site, not a single household — use sparingly.
*/
-export async function isSuperAdmin(): Promise {
- const supabase = await createServer();
+export async function isSuperAdmin(client?: SupabaseClient): Promise {
+ // `client` is the caller the request actually authenticated. A route that
+ // accepts a bearer token (/api/ai does, ahead of cookies) must ask about
+ // THAT principal: the cookie default would answer for whoever else's session
+ // the browser carries — an ordinary bearer with an admin's cookie passed,
+ // an admin's bearer with no cookie was refused (review on #585).
+ const supabase = client ?? await createServer();
const { data: auth, error: authError } = await supabase.auth.getUser();
if (authError) {
if (!isSessionMissing(authError)) console.error('[auth] super-admin user lookup failed', authError);
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 000000000..d0162df1c
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/scripts/page-audit-report.mjs b/scripts/page-audit-report.mjs
new file mode 100644
index 000000000..6c1187c55
--- /dev/null
+++ b/scripts/page-audit-report.mjs
@@ -0,0 +1,74 @@
+#!/usr/bin/env node
+// Turns scripts/page-audit.mjs output into the markdown rows finalaudit.md's
+// "Page audit — every page" section keeps, so every bot writes the same shape.
+//
+// node scripts/page-audit-report.mjs --label "prod desktop" a.jsonl [b.jsonl …]
+//
+// One row per route. Rows that share a dynamic pattern (every /blog/)
+// collapse into one row with counts, and name each page that did not pass.
+// A later file wins over an earlier one for the same path, so a re-check run
+// passed last replaces the first attempt.
+import { readFileSync } from 'node:fs';
+
+const args = process.argv.slice(2);
+const li = args.indexOf('--label');
+const label = li === -1 ? '' : args[li + 1];
+const files = args.filter((a, i) => a.endsWith('.jsonl') && args[i - 1] !== '--label');
+
+const byPath = new Map();
+for (const f of files) {
+ for (const line of readFileSync(f, 'utf8').split('\n')) {
+ if (!line.trim()) continue;
+ const r = JSON.parse(line);
+ byPath.set(r.path, r);
+ }
+}
+
+// Collapse families of generated pages into their pattern.
+const PATTERNS = [
+ [/^\/blog\/[^/]+$/, '/blog/[slug]'],
+ [/^\/blog\/category\/[^/]+$/, '/blog/category/[category]'],
+ [/^\/features\/[^/]+$/, '/features/[slug]'],
+ [/^\/resources\/[^/]+$/, '/resources/[slug]'],
+ [/^\/services\/[^/]+$/, '/services/[slug]'],
+ [/^\/reviews\/[^/]+$/, '/reviews/[slug]'],
+ [/^\/p\/[^/]+$/, '/p/[slug]'],
+ [/^\/s\/[^/]+$/, '/s/[slug]'],
+];
+const patternOf = (p) => {
+ const path = p.split('?')[0];
+ for (const [re, name] of PATTERNS) if (re.test(path)) return name;
+ return path;
+};
+
+function problems(r) {
+ const out = [];
+ if (r.error) out.push(r.error);
+ if (r.status != null && r.status >= 400) out.push(`HTTP ${r.status}`);
+ for (const e of r.pageErrors ?? []) out.push(`uncaught: ${e}`);
+ for (const b of r.badRequests ?? []) out.push(`request ${b}`);
+ if (!(r.badRequests ?? []).length) for (const c of r.consoleErrors ?? []) out.push(`console: ${c}`);
+ for (const s of r.smells ?? []) out.push(`renders ${s}`);
+ if ((r.overflow ?? 0) > 1) out.push(`overflows ${r.overflow}px${r.mobile ? ' at 390' : ''}`);
+ return out;
+}
+
+const groups = new Map();
+for (const r of byPath.values()) {
+ const key = patternOf(r.path);
+ if (!groups.has(key)) groups.set(key, []);
+ groups.get(key).push(r);
+}
+
+const esc = (s) => String(s).replace(/\|/g, '\\|').replace(/\n/g, ' ');
+for (const [route, rows] of [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
+ const failing = rows.filter((r) => r.verdict !== 'PASS');
+ const lands = [...new Set(rows.map((r) => (r.finalPath ?? '').split('?')[0]).filter((p) => p && p !== route))];
+ const status = failing.length ? '❌ CHECK' : '✅ PASS';
+ const count = rows.length > 1 ? `${rows.length - failing.length}/${rows.length} pages clean` : '';
+ const notes = failing.slice(0, 3)
+ .map((r) => `${rows.length > 1 ? `${r.path}: ` : ''}${problems(r).slice(0, 2).join('; ')}`)
+ .join(' · ');
+ const redirect = lands.length === 1 && rows.length === 1 ? `→ ${lands[0]}` : '';
+ console.log(`| ${esc(route)} | ${esc(label)} | ${status} | ${esc([count, redirect, notes].filter(Boolean).join(' '))} |`);
+}
diff --git a/scripts/page-audit.mjs b/scripts/page-audit.mjs
new file mode 100644
index 000000000..8180da14a
--- /dev/null
+++ b/scripts/page-audit.mjs
@@ -0,0 +1,236 @@
+#!/usr/bin/env node
+// Per-page audit crawler for the "every page on bubaly.com" pass in
+// finalaudit.md ("Page audit — every page"). Any bot can run it; it only reads.
+//
+// node scripts/page-audit.mjs --base https://www.bubaly.com --sitemap \
+// --out /tmp/pages.jsonl [--paths file.txt] [--storage state.json] \
+// [--concurrency 4] [--mobile] [--locale fr-FR] [--interact]
+//
+// For each path it loads the page in Chromium and records, as one JSON line:
+// status the document's HTTP status (after redirects)
+// finalPath where it ended up (a login redirect is data, not a failure)
+// pageErrors uncaught exceptions thrown in the page
+// consoleErrors console.error messages
+// badRequests same-origin subresources that answered >= 400 or failed
+// h1 number of elements
+// title document.title
+// overflow horizontal overflow in px at the viewport width
+// smells visible text that should never render: "undefined", "NaN",
+// "[object Object]", an error boundary, or a raw i18n key
+// links same-origin hrefs found on the page (for the link pass)
+// interactions with --interact: every tab, and button in
+// (up to 40) is clicked in turn — never one that submits a form
+// and never one labelled like a delete, payment or send — and
+// each click that threw, logged an error, failed a request or
+// showed an error boundary is listed with what it did. This
+// one writes (to whatever `--base` is), so it is for a local
+// stack, not production.
+//
+// It judges nothing itself; `verdict` is a mechanical summary (PASS when the
+// document answered < 400 and none of the lists above has an entry), and a
+// human or bot records the real status in finalaudit.md after looking.
+import { chromium } from 'playwright';
+import { readFileSync, writeFileSync, appendFileSync } from 'node:fs';
+
+const args = process.argv.slice(2);
+const opt = (name, fallback) => {
+ const i = args.indexOf(`--${name}`);
+ return i === -1 ? fallback : args[i + 1];
+};
+const flag = (name) => args.includes(`--${name}`);
+
+const base = (opt('base', 'https://www.bubaly.com')).replace(/\/$/, '');
+const out = opt('out', 'page-audit.jsonl');
+const concurrency = Number(opt('concurrency', '4'));
+const storage = opt('storage', undefined);
+const mobile = flag('mobile');
+// The browser's language: the site picks its locale from the NEXT_LOCALE
+// cookie or, failing that, Accept-Language, which this sets.
+const locale = opt('locale', 'en-US');
+const origin = new URL(base).origin;
+const interact = flag('interact');
+if (interact && /bubaly\.com$/.test(new URL(base).hostname)) {
+ console.error('--interact clicks controls that can write; point it at a local stack, not production');
+ process.exit(2);
+}
+
+async function loadPaths() {
+ const paths = new Set();
+ const file = opt('paths', undefined);
+ if (file) for (const line of readFileSync(file, 'utf8').split('\n')) if (line.trim()) paths.add(line.trim());
+ if (flag('sitemap')) {
+ const xml = await (await fetch(`${base}/sitemap.xml`)).text();
+ for (const m of xml.matchAll(/([^<]+)<\/loc>/g)) {
+ const u = new URL(m[1]);
+ paths.add(u.pathname + u.search);
+ }
+ }
+ return [...paths];
+}
+
+const SMELLS = [
+ [/\bundefined\b/, 'undefined'],
+ [/\bNaN\b/, 'NaN'],
+ [/\[object Object\]/, '[object Object]'],
+ [/Something went wrong|Application error|Unhandled Runtime Error/i, 'error boundary'],
+ // A catalogue key that was rendered instead of translated: dotted camelCase
+ // with no spaces, e.g. "billing.couldNotLoadPlans".
+ [/(?:^|\s)([a-z][a-zA-Z0-9]+\.[a-z][a-zA-Z0-9]{3,}(?:\.[a-zA-Z0-9]+)*)(?=\s|$)/, 'raw i18n key'],
+];
+
+// The controls --interact clicks, tagged in document order so the Nth one can
+// be found again after a click re-rendered the page.
+const CONTROLS = '[role="tab"],[aria-haspopup]:not([aria-haspopup="false"]),button[aria-expanded],summary,button,a[role="button"]';
+const NEVER = /delete|remove|sign ?out|log ?out|cancel|reset|archive|revoke|disconnect|leave|pay|send|buy|purchase|approve|decline|deny|reject|confirm|publish|charge|call|dial|refund|transfer|withdraw|unsubscribe|block|lock|emergency|sos|panic/i;
+
+async function tagControls(page) {
+ return page.evaluate(([sel, never]) => {
+ const neverRe = new RegExp(never, 'i');
+ const main = document.querySelector('main') ?? document.body;
+ let n = 0;
+ for (const el of document.querySelectorAll('[data-audit-i]')) el.removeAttribute('data-audit-i');
+ for (const el of main.querySelectorAll(sel)) {
+ const label = (el.getAttribute('aria-label') || el.textContent || '').replace(/\s+/g, ' ').trim();
+ const r = el.getBoundingClientRect();
+ if (!r.width || !r.height || el.closest('[aria-hidden="true"],[inert]') || el.disabled) continue;
+ // A button with no type submits the form it sits in; outside a form it
+ // is an ordinary button.
+ if (el.tagName === 'BUTTON' && el.type === 'submit' && el.form) continue;
+ if (el.closest('nav,header,footer,[data-sidebar]')) continue;
+ if (!label || neverRe.test(label)) continue;
+ el.setAttribute('data-audit-i', String(n++));
+ }
+ return n;
+ }, [CONTROLS, NEVER.source]);
+}
+
+async function clickThrough(page, path, seen) {
+ const failed = [];
+ let clicked = 0;
+ const count = Math.min(await tagControls(page), 40);
+ for (let i = 0; i < count; i++) {
+ const before = [seen.pageErrors.length, seen.consoleErrors.length, seen.badRequests.length];
+ const el = page.locator(`[data-audit-i="${i}"]`).first();
+ const label = ((await el.getAttribute('aria-label').catch(() => null)) || (await el.innerText().catch(() => '')) || '').replace(/\s+/g, ' ').trim().slice(0, 60);
+ try {
+ await el.click({ timeout: 3_000 });
+ clicked += 1;
+ await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {});
+ } catch {
+ continue; // covered or detached by the last click: not this control's defect
+ }
+ const boundary = await page.evaluate(() => /Something went wrong|This page hit a snag|Application error/i.test(document.body?.innerText ?? ''));
+ const threw = seen.pageErrors.slice(before[0]);
+ const logged = seen.consoleErrors.slice(before[1]);
+ const requests = seen.badRequests.slice(before[2]);
+ if (boundary || threw.length || logged.length || requests.length) failed.push({ label, boundary, threw, logged, requests });
+ // Put the page back: close what opened, or reload if the click navigated.
+ await page.keyboard.press('Escape').catch(() => {});
+ const here = new URL(page.url()).pathname + new URL(page.url()).search;
+ if (here !== path || boundary) {
+ await page.goto(base + path, { waitUntil: 'networkidle', timeout: 45_000 }).catch(() => {});
+ }
+ await tagControls(page);
+ }
+ return { controls: count, clicked, failed };
+}
+
+async function auditOne(context, path) {
+ const page = await context.newPage();
+ const pageErrors = [];
+ const consoleErrors = [];
+ const badRequests = [];
+ page.on('pageerror', (e) => pageErrors.push(String(e.message).slice(0, 300)));
+ page.on('console', (m) => {
+ if (m.type() !== 'error') return;
+ // "Failed to load resource" names no URL in its text; its location does.
+ const where = /^Failed to load resource/.test(m.text()) && m.location()?.url ? ` <${m.location().url.replace(origin, '')}>` : '';
+ consoleErrors.push(m.text().slice(0, 300) + where);
+ });
+ page.on('requestfailed', (r) => {
+ const f = r.failure()?.errorText ?? '';
+ if (r.url().startsWith(origin) && !/ERR_ABORTED|NS_BINDING_ABORTED/.test(f)) badRequests.push(`${f} ${r.url().replace(origin, '')}`);
+ });
+ page.on('response', (r) => {
+ if (r.url().startsWith(origin) && r.status() >= 400 && r.request().resourceType() !== 'document') {
+ // Who answered a 5xx matters: a sandbox egress proxy's 502 carries no
+ // `server: Vercel`/`x-vercel-id`, and is not the site's defect.
+ const h = r.headers();
+ const via = r.status() >= 500 ? ` [server=${h.server ?? '-'}${h['x-vercel-id'] ? ' vercel' : ''}]` : '';
+ badRequests.push(`${r.status()} ${r.url().replace(origin, '')}${via}`);
+ }
+ });
+ const row = { path, base, mobile, locale, at: new Date().toISOString() };
+ try {
+ const res = await page.goto(base + path, { waitUntil: 'networkidle', timeout: 45_000 });
+ row.status = res?.status() ?? null;
+ if (res && res.status() >= 500) row.server = `${res.headers().server ?? '-'}${res.headers()['x-vercel-id'] ? ' vercel' : ''}`;
+ row.finalPath = new URL(page.url()).pathname + new URL(page.url()).search;
+ // Many signed-in pages paint a skeleton, then their heading once the
+ // client-side read lands, after the network went idle. Give the heading a
+ // moment before counting it, or the count measures the skeleton.
+ await page.waitForSelector('h1', { timeout: 3_000 }).catch(() => {});
+ const probe = await page.evaluate(() => {
+ const text = document.body?.innerText ?? '';
+ const links = [...document.querySelectorAll('a[href]')].map((a) => a.getAttribute('href'));
+ return {
+ h1: document.querySelectorAll('h1').length,
+ title: document.title,
+ overflow: Math.max(0, document.documentElement.scrollWidth - window.innerWidth),
+ text,
+ links,
+ };
+ });
+ row.h1 = probe.h1;
+ row.title = probe.title;
+ row.overflow = probe.overflow;
+ row.smells = [];
+ for (const [re, name] of SMELLS) {
+ const m = probe.text.match(re);
+ if (m) row.smells.push(`${name}: ${(m[1] ?? m[0]).slice(0, 80)}`);
+ }
+ row.links = [...new Set(probe.links
+ .filter((h) => h && (h.startsWith('/') || h.startsWith(origin)) && !h.startsWith('//'))
+ .map((h) => (h.startsWith(origin) ? h.slice(origin.length) : h).split('#')[0])
+ .filter(Boolean))];
+ if (interact && !row.error) row.interactions = await clickThrough(page, path, { pageErrors, consoleErrors, badRequests });
+ } catch (e) {
+ row.error = String(e.message).split('\n')[0].slice(0, 300);
+ }
+ row.pageErrors = pageErrors;
+ row.consoleErrors = consoleErrors;
+ row.badRequests = [...new Set(badRequests)];
+ const clean = !row.error && row.status != null && row.status < 400
+ && !pageErrors.length && !consoleErrors.length && !row.badRequests.length
+ && !(row.smells?.length) && !(row.overflow > 1) && !(row.interactions?.failed.length);
+ row.verdict = clean ? 'PASS' : 'CHECK';
+ await page.close();
+ return row;
+}
+
+const paths = await loadPaths();
+writeFileSync(out, '');
+// A host whose preinstalled Chromium does not match this Playwright build
+// (a cloud sandbox, say) points at it with PAGE_AUDIT_CHROMIUM.
+const browser = await chromium.launch(process.env.PAGE_AUDIT_CHROMIUM ? { executablePath: process.env.PAGE_AUDIT_CHROMIUM } : {});
+const context = await browser.newContext({
+ storageState: storage,
+ locale,
+ viewport: mobile ? { width: 390, height: 844 } : { width: 1280, height: 900 },
+ isMobile: mobile,
+ hasTouch: mobile,
+});
+let next = 0;
+let done = 0;
+await Promise.all(Array.from({ length: concurrency }, async () => {
+ while (next < paths.length) {
+ const path = paths[next++];
+ const row = await auditOne(context, path);
+ appendFileSync(out, JSON.stringify(row) + '\n');
+ done += 1;
+ if (row.verdict !== 'PASS') console.log(`${row.verdict} ${path} ${row.status ?? row.error ?? ''}`);
+ if (done % 50 === 0) console.log(`… ${done}/${paths.length}`);
+ }
+}));
+await browser.close();
+console.log(`done ${done} pages → ${out}`);
diff --git a/tests/a-browser-asking-for-favicon-ico-gets-one.test.ts b/tests/a-browser-asking-for-favicon-ico-gets-one.test.ts
new file mode 100644
index 000000000..6838d2cc3
--- /dev/null
+++ b/tests/a-browser-asking-for-favicon-ico-gets-one.test.ts
@@ -0,0 +1,35 @@
+// Pages name their icons in , but a browser still asks for /favicon.ico
+// wherever there is no page to read them from (a file or JSON opened in a tab),
+// and link unfurlers and search engines ask for it first. bubaly.com answered
+// 404, which the 2026-09-27 page audit's interaction pass logged as a console
+// error on the signed-in pages. The middleware already skipped the path, as if
+// the file were there; now it is.
+import { readFileSync } from 'node:fs';
+import { describe, expect, it } from 'vitest';
+
+describe('/favicon.ico', () => {
+ const ico = readFileSync('public/favicon.ico');
+
+ it('is an icon file holding the brand icon at 16, 32 and 48 px', () => {
+ expect(ico.readUInt16LE(0)).toBe(0); // reserved
+ expect(ico.readUInt16LE(2)).toBe(1); // 1 = icon
+ const count = ico.readUInt16LE(4);
+ const sizes: number[] = [];
+ for (let i = 0; i < count; i++) {
+ const entry = 6 + 16 * i;
+ const size = ico.readUInt8(entry) || 256;
+ const length = ico.readUInt32LE(entry + 8);
+ const offset = ico.readUInt32LE(entry + 12);
+ const png = ico.subarray(offset, offset + length);
+ expect(png.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a');
+ // Each image is the brand icon of that size, byte for byte.
+ expect(png.equals(readFileSync(`public/icons/icon-${size}.png`))).toBe(true);
+ sizes.push(size);
+ }
+ expect(sizes).toEqual([16, 32, 48]);
+ });
+
+ it('is served as a static file, never run through the middleware', () => {
+ expect(readFileSync('middleware.ts', 'utf8')).toMatch(/\(\?!_next\/static\|_next\/image\|favicon\.ico\|/);
+ });
+});
diff --git a/tests/a-first-visit-with-two-events-records-both.test.ts b/tests/a-first-visit-with-two-events-records-both.test.ts
new file mode 100644
index 000000000..2477761bb
--- /dev/null
+++ b/tests/a-first-visit-with-two-events-records-both.test.ts
@@ -0,0 +1,98 @@
+// P-08 (2026-09-27 page audit). A visitor's first page can send two track
+// events at once (the page view and the one beside it on /ai). Both read "no
+// visitor", both insert, and the loser hit mkt_visitors_anonymous_id_key and
+// answered 503 — a console error on the page and a lost touchpoint. The loser
+// now reads the row it lost to and records against it.
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { NextRequest } from 'next/server';
+
+const db = vi.hoisted(() => ({
+ visitorReads: 0,
+ inserted: [] as { table: string; row: unknown }[],
+ createError: null as null | { code: string; message: string },
+ rereadFinds: true,
+}));
+
+vi.mock('@/lib/i18n/server', () => ({ getTranslations: async () => (key: string) => key }));
+vi.mock('@/lib/server/request-rate-limit', () => ({ enforceRequestRateLimit: async () => ({ ok: true }) }));
+vi.mock('@/lib/marketing/consent', () => ({
+ getConsentState: async () => ({ analytics: true }),
+ canRecordAnalytics: () => true,
+}));
+vi.mock('@/lib/marketing/visitor-cookie', () => ({
+ carriedVisitorId: () => 'visitor-1',
+ namesAnotherVisitor: () => false,
+}));
+vi.mock('@/lib/supabase/server', () => ({
+ createServiceClient: () => ({
+ from: (table: string) => {
+ const q: Record = {};
+ const chain = () => q;
+ let mode: 'select' | 'insert' = 'select';
+ let payload: unknown = null;
+ Object.assign(q, {
+ select: chain, eq: chain,
+ insert: (row: unknown) => { mode = 'insert'; payload = row; return q; },
+ maybeSingle: async () => {
+ if (table !== 'mkt_visitors') return { data: null, error: null };
+ db.visitorReads += 1;
+ // First read: no visitor yet (the race). Re-read after the conflict:
+ // the row the other request created.
+ if (db.visitorReads === 1) return { data: null, error: null };
+ return db.rereadFinds ? { data: { id: 'winner-row' }, error: null } : { data: null, error: null };
+ },
+ single: async () => (db.createError ? { data: null, error: db.createError } : { data: { id: 'new-row' }, error: null }),
+ then: (resolve: (v: unknown) => void) => {
+ if (mode === 'insert') db.inserted.push({ table, row: payload });
+ resolve({ data: null, error: null });
+ },
+ });
+ return q;
+ },
+ }),
+}));
+
+const { POST } = await import('@/app/api/mkt/track/route');
+
+function request() {
+ return new NextRequest('https://bubaly.test/api/mkt/track', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ source: 'direct', landingPath: '/ai' }),
+ });
+}
+
+describe('a first visit that fires two events at once', () => {
+ beforeEach(() => {
+ db.visitorReads = 0; db.inserted = []; db.createError = null; db.rereadFinds = true;
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ it('records the event that lost the create race against the row that won it', async () => {
+ db.createError = { code: '23505', message: 'duplicate key value violates unique constraint "mkt_visitors_anonymous_id_key"' };
+ const res = await POST(request());
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ ok: true, recorded: true });
+ expect(db.inserted.map((i) => i.table).sort()).toEqual(['mkt_sessions', 'mkt_touchpoints']);
+ expect(db.inserted.every((i) => (i.row as { visitor_id: string }).visitor_id === 'winner-row')).toBe(true);
+ });
+
+ it('records a visitor created without a race (control)', async () => {
+ const res = await POST(request());
+ expect(res.status).toBe(200);
+ expect(db.inserted.every((i) => (i.row as { visitor_id: string }).visitor_id === 'new-row')).toBe(true);
+ });
+
+ it('still answers 503 when the create fails for any other reason', async () => {
+ db.createError = { code: '57014', message: 'canceling statement due to statement timeout' };
+ expect((await POST(request())).status).toBe(503);
+ expect(db.inserted).toEqual([]);
+ });
+
+ it('answers 503 rather than guessing when the winner cannot be read back', async () => {
+ db.createError = { code: '23505', message: 'duplicate key' };
+ db.rereadFinds = false;
+ expect((await POST(request())).status).toBe(503);
+ expect(db.inserted).toEqual([]);
+ });
+});
diff --git a/tests/a-sidebar-layout-we-could-not-read-is-not-one-we-may-overwrite.test.ts b/tests/a-sidebar-layout-we-could-not-read-is-not-one-we-may-overwrite.test.ts
index 79d86ca21..418e10ff3 100644
--- a/tests/a-sidebar-layout-we-could-not-read-is-not-one-we-may-overwrite.test.ts
+++ b/tests/a-sidebar-layout-we-could-not-read-is-not-one-we-may-overwrite.test.ts
@@ -25,6 +25,12 @@ vi.mock('react', async (original) => ({
useMemo: (factory: () => unknown) => factory(),
useCallback: (fn: unknown) => fn,
useId: () => 'test-sidebar',
+ // A ref is a slot that keeps its object across renders, like useState's.
+ useRef: (initial: unknown) => {
+ const index = mocks.cursor++;
+ if (!(index in mocks.slots)) mocks.slots[index] = { current: initial };
+ return mocks.slots[index];
+ },
useState: (initial: unknown) => {
const index = mocks.cursor++;
if (!(index in mocks.slots)) mocks.slots[index] = typeof initial === 'function' ? initial() : initial;
@@ -231,4 +237,50 @@ describe('a sidebar layout we could not read is not one we may overwrite', () =>
expect(mocks.save).toHaveBeenCalledWith({ keys: [...DEFAULT_SIDEBAR_NAV_KEYS, PAID_CFO] });
expect(mocks.error).not.toHaveBeenCalled();
});
+
+ it('reads again when the router cancelled the read, instead of reporting it failed', async () => {
+ // A client redirect landing while the server action is in flight rejects
+ // it with a network TypeError; the sidebar stays mounted (2026-09-27 page
+ // audit: /dashboard/vacations/ → /overview logged "preference read
+ // failed" and closed the write gate on a read that never failed).
+ vi.useFakeTimers();
+ try {
+ mocks.load
+ .mockRejectedValueOnce(new TypeError('Failed to fetch'))
+ .mockResolvedValue({ nav: REAL_LAYOUT, children: null });
+ const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
+ let tree = await mounted();
+ expect(alerts(tree)).toEqual([]);
+ await vi.advanceTimersByTimeAsync(800);
+ tree = render();
+ await Promise.resolve();
+ tree = render();
+ expect(mocks.load).toHaveBeenCalledTimes(2);
+ expect(alerts(tree)).toEqual([]);
+ expect(logged).not.toHaveBeenCalledWith('[sidebar] preference read failed', expect.anything());
+ expect(storage.get(SIDEBAR_NAV_STORAGE_KEY)).toBe(JSON.stringify(REAL_LAYOUT));
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('still reports a read that keeps failing (control)', async () => {
+ vi.useFakeTimers();
+ try {
+ mocks.load.mockRejectedValue(new TypeError('Failed to fetch'));
+ const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
+ await mounted();
+ for (let i = 1; i <= 2; i += 1) {
+ await vi.advanceTimersByTimeAsync(800 * i);
+ render(); // the timer bumped `reload`; this render runs the effect again
+ await vi.advanceTimersByTimeAsync(0); // let that read reject
+ }
+ const tree = render();
+ expect(mocks.load).toHaveBeenCalledTimes(3);
+ expect(logged).toHaveBeenCalledWith('[sidebar] preference read failed', expect.any(TypeError));
+ expect(alerts(tree).join(' ')).toContain('could not be loaded');
+ } finally {
+ vi.useRealTimers();
+ }
+ });
});
diff --git a/tests/a-super-admin-page-and-its-endpoint-agree.test.ts b/tests/a-super-admin-page-and-its-endpoint-agree.test.ts
new file mode 100644
index 000000000..2c60dab38
--- /dev/null
+++ b/tests/a-super-admin-page-and-its-endpoint-agree.test.ts
@@ -0,0 +1,89 @@
+// requireFeature lets a super administrator through; refuseUnlessEntitled, the
+// gate on the endpoints behind those pages, did not. So for a super admin whose
+// own family is not on Family+, /dashboard/autopilot and /dashboard/briefing
+// rendered and then got 403 from /api/autopilot/scan and /api/ai/briefing
+// (2026-09-27 page audit). The endpoint gate now asks the same question, but
+// only after the family itself was refused, and a failed lookup stays a refusal.
+//
+// Review on #585: the question must be asked of the caller the endpoint
+// authenticated. /api/ai takes a bearer token ahead of cookies and hands the
+// gate that bearer-bound client; asking a fresh cookie client instead let an
+// ordinary bearer through on an admin's cookie and refused an admin's bearer
+// with no cookie. `isSuperAdmin` itself runs here (only the clients are fake),
+// so its credential choice is what is exercised.
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+type User = { id: string; email: string } | null;
+
+function client(user: User, opts: { fail?: boolean } = {}) {
+ return {
+ auth: {
+ getUser: vi.fn(async () => {
+ if (opts.fail) throw new Error('auth unavailable');
+ return { data: { user }, error: null };
+ }),
+ },
+ rpc: vi.fn(async () => ({ data: false, error: null })),
+ };
+}
+
+const ADMIN = { id: 'admin', email: 'site-admin@example.test' };
+const ORDINARY = { id: 'ordinary', email: 'parent@example.test' };
+
+const h = vi.hoisted(() => ({
+ entitlement: { allowed: false, reason: 'plan', needLevel: 2, planLevel: 1 } as Record,
+ cookie: null as unknown,
+}));
+
+vi.mock('server-only', () => ({}));
+vi.mock('@/lib/server/feature-entitlement', () => ({ resolveFeatureEntitlement: async () => h.entitlement }));
+vi.mock('@/lib/supabase/server', () => ({ createServer: async () => h.cookie }));
+
+const { refuseUnlessEntitled } = await import('@/lib/server/route-feature-gate');
+
+const gate = (db: unknown) => refuseUnlessEntitled(db as never, 'fam', ['/dashboard/autopilot']);
+
+describe('a feature endpoint and the page in front of it', () => {
+ beforeEach(() => {
+ vi.stubEnv('SUPER_ADMIN_EMAILS', ADMIN.email);
+ h.entitlement = { allowed: false, reason: 'plan', needLevel: 2, planLevel: 1 };
+ h.cookie = client(null);
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ it('refuses a family below the plan (control)', async () => {
+ expect((await gate(client(ORDINARY)))?.status).toBe(403);
+ });
+
+ it('lets a super administrator through, as the page does', async () => {
+ expect(await gate(client(ADMIN))).toBeNull();
+ });
+
+ it('does not ask who the caller is when the family is entitled', async () => {
+ h.entitlement = { allowed: true, planLevel: 2 };
+ const db = client(ORDINARY);
+ expect(await gate(db)).toBeNull();
+ expect(db.auth.getUser).not.toHaveBeenCalled();
+ });
+
+ it('treats a failed super-admin lookup as a refusal, never as access', async () => {
+ expect((await gate(client(ADMIN, { fail: true })))?.status).toBe(403);
+ });
+
+ it('refuses an ordinary bearer even when the browser also carries an admin cookie', async () => {
+ h.cookie = client(ADMIN);
+ expect((await gate(client(ORDINARY)))?.status).toBe(403);
+ });
+
+ it('lets an admin bearer through with no cookie at all', async () => {
+ h.cookie = client(null);
+ expect(await gate(client(ADMIN))).toBeNull();
+ });
+
+ it('asks the client it was given, not the cookie session', async () => {
+ const cookie = client(ADMIN);
+ h.cookie = cookie;
+ await gate(client(ORDINARY));
+ expect(cookie.auth.getUser).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/an-ai-feature-request-is-recorded-not-refused.test.ts b/tests/an-ai-feature-request-is-recorded-not-refused.test.ts
new file mode 100644
index 000000000..6dc882812
--- /dev/null
+++ b/tests/an-ai-feature-request-is-recorded-not-refused.test.ts
@@ -0,0 +1,64 @@
+// P-10 (2026-09-27 page audit). 0255 lets a member insert an `ai_requests` row
+// of kind `concierge` and no other; "feature, routine, trigger and handle_it
+// requests are filed by server code". But `createRequest` filed every kind on
+// the caller's client, so every `withAiRequest` surface — the assistant, the
+// brief, the habit and relationship coaches — had its `feature` row refused by
+// RLS. On a local stack with every migration, a crawl that used them all left
+// 13 concierge rows and not one feature row, and the Free plan's monthly AI
+// allowance, which counts these rows, never counted an assistant turn.
+import { readFileSync } from 'node:fs';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { ServiceScope } from '@/lib/services/types';
+
+type Insert = { client: string; row: Record };
+const inserts: Insert[] = [];
+
+function fakeClient(name: string) {
+ return {
+ from: () => ({
+ insert: (row: Record) => {
+ inserts.push({ client: name, row });
+ return { select: () => ({ single: async () => ({ data: { id: `${name}-row` }, error: null }) }) };
+ },
+ }),
+ };
+}
+
+vi.mock('server-only', () => ({}));
+vi.mock('@/lib/supabase/server', () => ({ createServiceClient: () => fakeClient('service') }));
+
+const { createRequest } = await import('@/lib/ai/runs/store');
+
+const member = { db: fakeClient('member'), familyId: 'fam-1', userId: 'u1', memberId: 'm1', role: 'parent', actorKind: 'member' } as unknown as ServiceScope;
+
+describe('who files an AI request', () => {
+ beforeEach(() => { inserts.length = 0; });
+
+ it('files a feature request on the ledger client, for the verified family and person', async () => {
+ const res = await createRequest(member, { requestText: 'brief me', kind: 'feature', feature: 'briefing.morning' });
+ expect(res).toEqual({ ok: true, data: { id: 'service-row' } });
+ expect(inserts).toHaveLength(1);
+ expect(inserts[0].client).toBe('service');
+ expect(inserts[0].row).toMatchObject({ family_id: 'fam-1', requested_by: 'u1', requested_by_member_id: 'm1', kind: 'feature', status: 'queued' });
+ });
+
+ it('keeps a concierge request on the person\'s own client, where RLS checks it (control)', async () => {
+ await createRequest(member, { requestText: 'plan our week', kind: 'concierge' });
+ expect(inserts.map((i) => i.client)).toEqual(['member']);
+ });
+
+ it('treats an unknown kind as the concierge, on the person\'s client', async () => {
+ await createRequest(member, { requestText: 'x', kind: 'made-up' });
+ expect(inserts).toEqual([expect.objectContaining({ client: 'member', row: expect.objectContaining({ kind: 'concierge' }) })]);
+ });
+
+ it('uses the client a caller hands it (the cron, the executor)', async () => {
+ await createRequest(member, { requestText: 'weekly', kind: 'routine' }, { db: fakeClient('cron') as never });
+ expect(inserts.map((i) => i.client)).toEqual(['cron']);
+ });
+
+ it('matches the policy it has to satisfy: a member may insert the concierge kind only', () => {
+ const sql = readFileSync('supabase/migrations/0255_ai_runtime_lockdown.sql', 'utf8');
+ expect(sql).toMatch(/create policy ai_requests_insert[\s\S]*?and kind = 'concierge'/);
+ });
+});
diff --git a/tests/an-ai-request-row-never-carries-what-was-typed.test.ts b/tests/an-ai-request-row-never-carries-what-was-typed.test.ts
new file mode 100644
index 000000000..2884c5011
--- /dev/null
+++ b/tests/an-ai-request-row-never-carries-what-was-typed.test.ts
@@ -0,0 +1,60 @@
+// P-10 review (#585). `ai_requests` is readable by every active member of the
+// family (0250's SELECT policy). Once P-10 let feature rows be filed at all,
+// every surface that handed `withAiRequest` the person's own words — the
+// assistant's message, the chef request, a marketplace question, a chore
+// prompt, a post topic, a trip destination — would have shown them to the rest
+// of the household. The rule is now structural: the recorded text is a fixed
+// label at every call site. This scan holds every call site to it; the
+// assistant's behaviour (the model still gets the message) is pinned in
+// tests/assistant-engine.test.ts.
+import { readdirSync, readFileSync, statSync } from 'node:fs';
+import { join } from 'node:path';
+import { describe, expect, it } from 'vitest';
+
+function walk(dir: string, out: string[] = []): string[] {
+ for (const name of readdirSync(dir)) {
+ const p = join(dir, name);
+ if (statSync(p).isDirectory()) walk(p, out);
+ else if (/\.(ts|tsx)$/.test(name)) out.push(p);
+ }
+ return out;
+}
+
+type Site = { file: string; line: number; text: string | null };
+
+function callSites(): Site[] {
+ const sites: Site[] = [];
+ for (const file of [...walk('app'), ...walk('lib')]) {
+ const src = readFileSync(file, 'utf8');
+ for (const m of src.matchAll(/\bwithAiRequest\(/g)) {
+ const before = src.slice(Math.max(0, m.index - 30), m.index);
+ if (/function\s+$/.test(before)) continue; // the definition itself
+ const lineStart = src.lastIndexOf('\n', m.index) + 1;
+ if (/^\s*(\/\/|\*|\/\*)/.test(src.slice(lineStart, m.index)) || src.slice(lineStart, m.index).includes('//')) continue; // prose
+ const after = src.slice(m.index, m.index + 1500);
+ const spec = after.match(/\{\s*feature:[^\n]*?\btext:\s*([^,}]+)/);
+ sites.push({ file, line: src.slice(0, m.index).split('\n').length, text: spec ? spec[1].trim() : null });
+ }
+ }
+ return sites;
+}
+
+describe('the text an AI request row records', () => {
+ const sites = callSites();
+
+ it('finds every call site (a scan that finds nothing proves nothing)', () => {
+ expect(sites.length).toBeGreaterThanOrEqual(35);
+ });
+
+ it('is a fixed label at every call site: a plain string literal or a catalogue string', () => {
+ const offenders = sites
+ .filter((s) => s.text === null || !/^('[^'`$]*'|t\('[^']+'\))$/.test(s.text))
+ .map((s) => `${s.file}:${s.line} text: ${s.text ?? '(no inline { feature, text } spec)'}`);
+ expect(offenders).toEqual([]);
+ });
+
+ it('never names the variables that carry what a person typed', () => {
+ const typed = /\b(message|input\.message|request|prompt|q|topic|input\.topic|destination|input\.destination)\b/;
+ expect(sites.filter((s) => s.text && typed.test(s.text) && !s.text.startsWith("'"))).toEqual([]);
+ });
+});
diff --git a/tests/assistant-engine.test.ts b/tests/assistant-engine.test.ts
index e55eec55c..22480cca7 100644
--- a/tests/assistant-engine.test.ts
+++ b/tests/assistant-engine.test.ts
@@ -58,6 +58,13 @@ vi.mock('@/lib/ai/actions', () => ({
],
runAction: (...args: unknown[]) => runAction(...args),
}));
+// The turn's `ai_requests` row is a `feature` request, which server code files
+// on the ledger (service) client since P-10 — a member's client is refused it by
+// 0255. The same fake stands in for both, so the payload is still asserted.
+vi.mock('@/lib/supabase/server', async (importOriginal) => ({
+ ...(await importOriginal()),
+ createServiceClient: () => supabase,
+}));
import {
buildAssistantSystemPrompt, createAssistantStream, finalizeAssistantContent, persistAssistantTurn,
@@ -191,8 +198,11 @@ describe('runAssistantTurn (JSON transport)', () => {
requested_by: input.userId,
conversation_id: input.conversationId,
feature: 'assistant.turn',
- request_text: 'Plan tacos for Saturday',
+ // A fixed label, never what was typed: every active family member can
+ // read `ai_requests` (0250), and the message is the person's own.
+ request_text: 'Assistant turn',
});
+ expect(JSON.stringify(requestInsert!.rows)).not.toContain('Plan tacos for Saturday');
const rows = messageInsert!.rows as Row[];
expect(rows[0]).toMatchObject({ role: 'user', content: 'Plan tacos for Saturday', conversation_id: input.conversationId });
expect(rows[1]).toMatchObject({ role: 'assistant', content: 'I’ve done part of that. Could not create the chore.' });
@@ -234,7 +244,8 @@ describe('createAssistantStream (SSE transport)', () => {
// family reporting "it stops halfway" can be told which transport they were
// on — the two fail in different ways.
expect(inserts.find((i) => i.table === 'ai_requests')?.rows)
- .toMatchObject({ feature: 'assistant.stream', conversation_id: input.conversationId });
+ .toMatchObject({ feature: 'assistant.stream', conversation_id: input.conversationId, request_text: 'Assistant stream' });
+ expect(JSON.stringify(inserts.find((i) => i.table === 'ai_requests')?.rows)).not.toContain(input.message);
});
it('falls back to a non-streaming run when the stream dies before any text', async () => {
@@ -325,3 +336,37 @@ describe('helpers', () => {
expect(updates[0].patch).toEqual({ model: 'x' });
});
});
+
+// P-10 review: once feature rows were filed at all, `request_text` became
+// readable by the whole household (0250's SELECT). What a person types to the
+// assistant reaches the model and their own conversation, never that row.
+describe('what was typed stays out of the request ledger', () => {
+ const PRIVATE = 'zq-private-phrase-7731 about my divorce lawyer';
+ const privateInput = { ...input, message: PRIVATE };
+
+ it('the JSON turn: the model gets the message, the ledger row does not', async () => {
+ provider.runTools.mockResolvedValueOnce({ text: 'Noted.', actions: [] });
+ const prepared = await prepareAssistantTurn(privateInput);
+ if (!prepared.ok) throw new Error('prepare failed');
+ await runAssistantTurn(privateInput, prepared.turn);
+ expect(JSON.stringify(provider.runTools.mock.calls.at(-1))).toContain(PRIVATE);
+ const request = inserts.find((i) => i.table === 'ai_requests');
+ expect(request, 'the turn must open a request row').toBeTruthy();
+ expect(JSON.stringify(request!.rows)).not.toContain('zq-private-phrase-7731');
+ const messages = inserts.find((i) => i.table === 'ai_messages')!.rows as Row[];
+ expect(messages[0]).toMatchObject({ role: 'user', content: PRIVATE });
+ });
+
+ it('the stream: the model gets the message, the ledger row does not', async () => {
+ provider.runToolsStream.mockImplementationOnce(async function* () {
+ yield { type: 'delta', text: 'Noted.' };
+ });
+ const prepared = await prepareAssistantTurn(privateInput);
+ if (!prepared.ok) throw new Error('prepare failed');
+ await readSse(createAssistantStream(privateInput, prepared.turn));
+ expect(JSON.stringify(provider.runToolsStream.mock.calls.at(-1))).toContain(PRIVATE);
+ const request = inserts.find((i) => i.table === 'ai_requests');
+ expect(request, 'the stream must open a request row').toBeTruthy();
+ expect(JSON.stringify(request!.rows)).not.toContain('zq-private-phrase-7731');
+ });
+});
diff --git a/tests/two-widgets-on-one-table-do-not-share-a-channel.test.ts b/tests/two-widgets-on-one-table-do-not-share-a-channel.test.ts
new file mode 100644
index 000000000..11c452028
--- /dev/null
+++ b/tests/two-widgets-on-one-table-do-not-share-a-channel.test.ts
@@ -0,0 +1,39 @@
+// /dashboard/calendar rendered its error screen: the calendar and its busyness
+// heatmap both call useRealtimeQuery on calendar_events, and both asked
+// realtime-js for the channel `calendar_events:`. realtime-js hands back
+// the channel already open under a topic, so the second widget's `.on()` ran
+// after the first widget's `.subscribe()` and threw. Found by the 2026-09-27
+// page audit (finalaudit.md, "Page audit — every page").
+import { readFileSync } from 'node:fs';
+import { describe, expect, it } from 'vitest';
+import { RealtimeClient } from '@supabase/realtime-js';
+
+const change = { event: '*', schema: 'public', table: 'calendar_events', filter: 'family_id=eq.fam-1' } as const;
+
+describe('two widgets reading one table', () => {
+ it('realtime-js reuses a topic, so a second listener on it throws (the mechanism)', () => {
+ const client = new RealtimeClient('ws://127.0.0.1:1/realtime/v1', { params: { apikey: 'x' } });
+ const first = client.channel('calendar_events:fam-1').on('postgres_changes', change, () => {}).subscribe();
+ const second = client.channel('calendar_events:fam-1');
+ expect(second).toBe(first);
+ expect(() => second.on('postgres_changes', change, () => {})).toThrow(/after .?subscribe/);
+ void client.removeAllChannels();
+ });
+
+ it('distinct topics get distinct channels, each taking its own listener (the fix)', () => {
+ const client = new RealtimeClient('ws://127.0.0.1:1/realtime/v1', { params: { apikey: 'x' } });
+ const a = client.channel('calendar_events:fam-1:r1').on('postgres_changes', change, () => {}).subscribe();
+ const b = client.channel('calendar_events:fam-1:r2');
+ expect(b).not.toBe(a);
+ expect(() => b.on('postgres_changes', change, () => {}).subscribe()).not.toThrow();
+ void client.removeAllChannels();
+ });
+
+ it('useRealtimeQuery opens its channel through ownChannel, one topic per subscription', () => {
+ const src = readFileSync('lib/hooks/use-realtime-query.ts', 'utf8');
+ expect(src).toMatch(/ownChannel\(supabase, spec\.name\)/);
+ expect(src).not.toMatch(/\.channel\(spec\.name\)/);
+ const own = readFileSync('lib/realtime/own-channel.ts', 'utf8');
+ expect(own).toMatch(/client\.channel\(`\$\{name\}:sub\$\{sequence\}`\)/);
+ });
+});