Post Millennium Renaissance
Interactive mountain bike tools built on real physics, and trail advocacy grounded in economic data. Castle Rock, CO.
翻譯準備中
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證Help pair skippers with crew
翻譯準備中
取自儲存庫 README
正在載入完整 README。 以下為已儲存的摘錄。
Help pair skippers with crew. The board that says who still needs a crew for Sunday.
At Hoover Sailing Club, crewed boats go short-handed or stay on the trailer most race days because
the skipper and a willing crew never found each other. Tender is a race-day board: a skipper posts
a need, the engine proposes crew by rung and tells them on their phones, someone taps I can, the
skipper accepts, and the board shows the match. Open to anyone who has learned to sail at Hoover,
member or not.檢視完整 README ↗Help pair skippers with crew. The board that says who still needs a crew for Sunday.
At Hoover Sailing Club, crewed boats go short-handed or stay on the trailer most race days because the skipper and a willing crew never found each other. Tender is a race-day board: a skipper posts a need, the engine proposes crew by rung and tells them on their phones, someone taps I can, the skipper accepts, and the board shows the match. Open to anyone who has learned to sail at Hoover, member or not.
The decisions and their reasoning are in docs/charter.md (ratified
2026-08-21) and one ADR per architecture decision in docs/adr/. The idea's
pressure-test is forged-idea.md. Read the charter before changing scope; it
carries the non-goals and the constraints a story must not violate.
| Decision | Choice | ADR |
|---|---|---|
| Language/runtime | TypeScript on Node 24 LTS | 001 |
| Framework | Next.js 16 | 002 |
| Data layer | Supabase Postgres via supabase-js, RLS, SQL migrations in supabase/migrations/ | 003 |
| Hosting & scheduler | Vercel Hobby + Supabase Free; the ladder clock is pg_cron | 004 |
| CI/CD & branches | GitHub Actions; develop (default) + release (production) + feature PRs | 005 |
| Testing | Vitest for the engine, pglite for RLS, jsdom for a click, Playwright smoke later | 006, 008 |
| Notifications — the bet | Web push from an installed PWA + email to the current rung (Resend) | 007 |
npm ci
npm run dev # http://localhost:3000
npm test # vitest: the engine (src/engine), the RLS harness (test/), the components
npm run lint
npm run typecheck
npm run check:live # read-only probe of the live Supabase project; needs .env.local
# first line: which of the 9 server env names THIS shell has (names only)
npm run migrate:live supabase/migrations/0015_anon_revoke.sql # applies it; -- --dry-run rehearses
npm run verify:migrations # reads pg_catalog: is the live project in the state the files describe?
npm run icons # re-render public/*.png from brand/hsc-mark-primary.svg (rarely)
npm run perf:floor -- --db-container supabase_db_<dir> # Lighthouse on /board and /post, at a fixture volume
npm run smoke -- --db-container supabase_db_<dir> # the core path in a real browser (CI runs it on every PR)
Node 24 (.nvmrc). Copy .env.example to .env.local — names only are committed, never values.
The engine is src/engine/ladder.ts, a pure function: a post, a pool and a clock in; the open
rung and the candidates (each carrying their own rung) out. Its test is the scaffold's one real
test: six mutations on 2026-08-21 reddened exactly the predicted 3, 1, 2, 3, 1, 1 of 14
(docs/adr/006-testing-strategy.md).
Installable to a home screen (#28). src/app/manifest.ts is the manifest, public/sw.js the
service worker, and src/install/ the "add to home screen" banner on /board. The worker is
online only and has no listener for network requests — a cached board would show a need that
has already been filled — and test/service-worker.test.ts enforces that. The icons under
public/ are committed; npm run icons regenerates them and test/manifest.test.ts reads their
real pixel sizes back out of the PNG headers.
The served half of that — /manifest.webmanifest and the icons as a real build returns them —
is test/manifest-served.test.ts, which runs only when pointed at a running server, because
CI runs the tests before the build:
npm run build && npm start &
TENDER_BASE_URL=http://localhost:3000 npx vitest run test/manifest-served.test.ts
next build bakes NEXT_PUBLIC_* into the proxy. To run a production build against a local
Supabase stack, those variables must be set for the build, not just for next start — the
proxy runs in the Edge runtime, where they are inlined rather than read at runtime. Build without
them and .env.local supplies the live project instead, so every local session is refused and
/board redirects to /join with the cookie sitting right there (measured 2026-08-25 on #28).
Component tests come in two kinds, and the split is deliberate. Most .test.tsx files
render with renderToStaticMarkup and assert the HTML a member is served — no effect runs and
no event can be dispatched, which is enough for anything decided at render time. The two files
that assert what happens after a click (src/auth/PasswordFields.test.tsx,
src/app/join/JoinForm.test.tsx, both #100) opt into jsdom with a // @vitest-environment
docblock on their first line. It is per file on purpose: vitest.config.ts stays
environment: "node", whose shape test/harness-budget.test.ts asserts for #78. Each of those
files declares the environment exactly ONCE — vitest reads that directive anywhere in a file,
comments included, so a docblock quoting it in full silently declares it a second time and the
real line can then be deleted with nothing going red (measured on #100).
A Chrome profile the suite cannot delete (#280). The Chrome-backed test files close their
browser through closeChrome (test/chrome.ts). That matters because browser.close() returns
only once Playwright has deleted the browser's temporary profile under %TEMP%, and on the owner's
machine that deletion sometimes never finishes. You see it as a line in an otherwise passing run:
#280: Chrome (pid 12345) exited, but Playwright was still deleting its temporary profile 10000 ms later, …
Before #280 the same thing ended the run success: false with every test passed, one Chrome file's
afterAll reading Hook timed out in 180000ms. That was 7 runs of 7 on 2026-10-12, in the checkout
and in a develop worktree alike. Each one leaves a playwright_chromiumdev_profile-* folder
behind whose Default\ files refuse to open, or be deleted, from node or PowerShell ("Access
denied"), although the folder's ACL grants you full control. DEBUG=pw:browser shows that Chrome's
starting temporary directories cleanup with no finished after it. The folder is locked while
Chrome is still running, and fifty Chromes launched the same way outside the suite never were.
The likely cause is the machine's antivirus, ESET Internet Security. A secured browser's profile gets
Enhanced data protection, which ESET documents as preventing unauthorized applications from
reading and writing the browser's user profile folders. Every chrome.exe on that machine also
hides its command line from other processes, which ESET's Enhanced memory protection would do.
Both are reasoned, not measured. Do not raise CHROME_CLOSE_TIMEOUT_MS for this: the time goes
on the deletion, not on Chrome's exit, which closeChrome still waits for in full. Removing the
leftover folders has not been tried.
The RLS harness (test/pglite.ts) applies supabase/migrations/*.sql to an in-memory
Postgres and runs SQL as anon, authenticated or service_role. Since #48 it reproduces
Supabase's default privileges for the first two before applying anything, so a "this role is shut
out" assertion is load-bearing rather than passing on a harness that never granted the role
anything. It deliberately does not reproduce them for service_role, which is what makes a
missing grant … to service_role redden here (that is why 0014 exists); the reasoning and the
measured cost of each choice are in that file's docstring.
What it still cannot see is a grant the live project holds that no migration makes and no default
explains — a hand grant in the SQL editor. npm run check:live is the instrument for that. It
probes with limit=0 and by GET so it can never write, and since #48 it reports, per table and
per function, whether the public anon key could still reach it — failing the run if any could.
Applying a migration is npm run migrate:live <file> (#114), and it does one thing a paste
cannot: it proves the payload arrived. It embeds the file in a dollar-quoted literal, asks
Postgres for the length, byte count and md5 of what it received, compares those against the file
on disk, and refuses if they disagree — before applying anything. A file compared against
itself would prove nothing, and on this machine a clipboard really does re-encode a file: the
characters at risk are the ones inside comment on … is '…' literals, which persist into the
database as schema documentation. Every migration here carries some — test/migrate-live.test.ts
asserts 0015's byte count exceeds its character count, so the hazard stays reachable rather than
being a number in prose that ages.
It takes a file from supabase/migrations/ and never SQL. That narrowing is the point: a
general "run this against production" command is what the token makes easy and what was
deliberately not built. -- --dry-run prints the plan and sends nothing, and needs no credential
at all. Note that npm run claims a --dry-run of its own, so both -- --dry-run and
--dry-run are honoured — a silently dropped flag here is a real apply somebody thinks is a
rehearsal, and an unknown flag is refused rather than ignored.
It needs SUPABASE_ACCESS_TOKEN in .env.local — a personal access token from the account
page, not a project key. It is scoped by project and by permission, and it must cover this
project and allow writes. Measured 2026-08-31 across three tokens: one scoped to another project
answered 403 to every tender endpoint; one scoped here but read-only read fine and answered
25006 to every write; only the third could apply anything. GET /v1/projects returns exactly the
projects a token covers, which is the one call that tells the three apart — and a 403 from this API
says privileges, never scope, so the message does not point at the cause.
It is still the widest credential in .env.local, so .env.example says how to revoke it and
test/migrate-live-scope.test.ts refuses the name reaching a place it should not. The service-role
key is not an alternative: it authenticates to this project's own API and cannot run DDL. Deciding
to apply is still the owner's; this changes who can carry it out, and whether the result is
verifiable.
Asking whether the migrations are in place is npm run verify:migrations (#117), and it is a
different question from check:live's. check:live probes as a client, over PostgREST, with the
anon key, so it can see tables and functions and nothing else — which leaves it blind to most of
what this repo's recent migrations do. It reads the same number either side of pasting 0011
(three check constraints), 0014 (one grant), 0015 (revokes and default privileges) or 0009's
two triggers. This reads pg_catalog with the management token instead, so a grant, a constraint,
a trigger, an index and a row-level-security flag are all in view. Neither command can answer the
other's question and neither replaces the other.
Its expectations are parsed out of supabase/migrations/*.sql, never listed in the script.
That is the whole design rather than a convenience: a hand-written expectation has the same author
as the migration, on the same day, from the same understanding, so it certifies agreement rather
than presence — and agrees with itself in exactly the case the command exists for, which is the
migration somebody wrote and forgot to apply. Adding a migration needs no edit to the command; a
statement in a shape nobody has written before is refused rather than skipped, so a kind it
cannot read can never be silently unchecked.
Two things it deliberately does not claim. It never says a migration was applied — a revoke of a
privilege nobody held and an update matching no rows both leave the database in the asserted state
without the file ever running, so every verdict is about state, and the run prints that in as many
words. And it names the statements nothing can testify to rather than counting them as passes:
today that is three backfills in 0011, a seed insert in 0005, and 0015's sequence sweep,
which has no sequence to sweep because no file here creates one.
Every query goes with read_only: true, so the platform enforces that this command cannot
write. That matters because omitting the flag connects a write-capable token as postgres with the
transaction open for writing: without it, a command whose whole purpose is to look would inspect
production over a connection that could change it.
0015 this project cannot reach (#118)0015 revokes anon from the default privileges on public, and it worked. ALTER DEFAULT PRIVILEGES with no FOR ROLE alters the current role's defaults and nothing else, though, and
this project has two roles holding default ACLs on that schema. Read 2026-09-01, read-only, from
pg_default_acl:
defaclrole | tables | sequences | functions |
|---|---|---|---|
postgres | postgres, authenticated, service_role | postgres, authenticated, service_role | postgres, authenticated, service_role |
supabase_admin | postgres, anon, authenticated, service_role | postgres, anon, authenticated, service_role | postgres, anon, authenticated, service_role |
So 0015 cleared anon from all three of postgres's rows, sequences included, and could never
have touched supabase_admin's. (The issue that found this recorded the postgres/sequences row
as absent — "nothing to revoke, so nothing was stored". Measured, the row is there and reads like
its two neighbours. Whether that cell was a mis-reading or the row was written since is not
knowable from here; the reading above is the one that was taken.)
Nothing this repo can apply will narrow the supabase_admin rows. Measured the same day:
pg_has_role('postgres', 'supabase_admin', 'member') is false, usage likewise, and postgres
is not a superuser on a Supabase project — so ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin …
is refused whatever credential this project holds. Closing it is an owner or Supabase-support
action, not a migration. It is recorded rather than fixed, deliberately: a statement that
measurably does nothing is worse than an absent one, because it reads as cover.
It is inert while it is inert, which is not the same as safe. A default ACL governs only the
objects its own role goes on to create, and every object in public here is created by the
migrations, which run as postgres. A platform-installed extension landing in public is the
realistic way that stops being true — and the exposure would then be silent, since 0015's sweep
covers what existed when it ran.
That is why verify:migrations now asserts the premise as well as the statement: every table,
sequence and function in public must be owned by the role that owns the tables the migrations
created. It fails on an empty population rather than passing, so a reading of "nothing has the
wrong owner" cannot come from having read nothing. The table above is a dated observation and will
age; the check is the part that stays current, and a red on it means the sweep is owed a re-run
over whatever arrived.
develop is the default and integration branch. Feature branches → PR → develop.release is Vercel's production branch. Merging into develop deploys nothing; promoting
develop → release is the deploy, and it is the owner's — after any new migration has been
applied to the live project (npm run migrate:live <file>; see Applying a migration).main is the backup branch: a known-good working version to fall back to if release
breaks and cannot be fixed in place. It is promoted from release by a pull request the
owner merges — from the branch production actually ran, so the backup is by construction a
state that has been live. It is never a base for new work, and a main that has moved is the
backup being taken rather than drift. (Owner directive 2026-09-01; supersedes ADR 005's
"retired at scaffold" consequence.)githooks/pre-push refuses direct pushes to develop, main, master and (via
githooks/owner-only) release. Enable it once per clone: git config core.hooksPath githooks.
It runs githooks/checks before any other push.Every page ends in a footer such as Tender v0.1.0 · 3c7759e · feature/169-build-stamp · built 2026-09-19 (#169). It answers "is production on the new one yet?" from the page itself rather
than from the Vercel dashboard, which a member cannot see and the owner cannot see from the water.
v0.1.0 is package.json's version, and that field is the one place the number lives.
Bump it in the PR that warrants it — edit the field, or npm version patch --no-git-tag-version
(the flag matters: the repo has no tags and the promotion flow is a PR, so a tag here would be
a second, unread record of the same fact). Nothing else reads the field. Forgetting the bump
degrades to "which commit", not "no information", because the next two parts change per build.VERCEL_GIT_COMMIT_SHA on Vercel and git rev-parse locally. Absent both, the
stamp omits it rather than the build failing over a footer.release, so a preview deployment cannot pass for production.next build evaluated next.config.ts, where all four are computed and
inlined through env. They are baked into the bundle, not read at request time, so the stamp on
a page is the stamp of the build serving it — a stale bundle cannot claim a newer one.A build the config did not stamp — vitest, for one — prints unstamped build in words. An empty footer would be indistinguishable from a page that has none.
npm run perf:floor measures /board and /post/[id] with Lighthouse — mobile, simulated
throttling — against a production build served locally with a fixture of 80 people, 45 race dates
and 50 posts, signed in through a real session cookie. It prices levers against a floor of 80; it
does not decide the framework. On a deployed build of release at that volume (#185,
2026-09-22) /board read 66, and 70 with a new footer layout shift fixed. ADR 002's lab
condition fired on that reading, and the owner decided the same day to stay on Next.js 16. The
framework is now reopened only by field data from members' phones, against thresholds
ADR 002 will carry before any such data exists. /post/[id] read 78,
and 82 with the footer fixed. Method, readings and the levers priced are in
docs/performance-floor.md.
A seeded run writes — 80 people, 45 dates, 50 posts — so it refuses any Supabase URL that is
not loopback before touching a row. It is the opposite shape from check:live and
verify:migrations, which are read-only by construction, and it says so rather than relying on the
flag being passed. --no-seed writes nothing and may therefore read the live project as a named
crew account; the doc's Against a deployment has both recipes, including the seeded Vercel
preview #185 used.
Two traps it guards, both of which produce a reassuring number rather than an error:
next build inlines NEXT_PUBLIC_* into the Edge proxy, so a build made without them
pointed at the local stack silently talks to whatever .env.local names. src/proxy.ts then
finds no valid session and 302s to /join — a small static form that scores well. The command
fetches each route with the cookie before spending two minutes on it and refuses anything but a
200, and refuses again afterwards if Lighthouse's own finalDisplayedUrl is not what was asked
for./board's three runs span 72–78 and
/post/[id]'s span 78–90 on identical builds, so the command reports every run beside the median
and warns when a route's runs straddle the floor rather than letting a lucky median pass silently.
That warning is not hypothetical: /post/[id] passes on its median of 83 with one run at 78.Everything that decides an outcome is in scripts/lighthouse-floor-core.mjs, exercised by
test/lighthouse-floor.test.ts with no stack, no browser and no network — the same split as
check-live.mjs and verify-migrations.mjs.
CI's smoke job drives the core path through a real browser on every pull request (#45, ADR
006's "Playwright smoke later"): two people seeded pre-confirmed with auth.admin.createUser, each
signing in through /join; the crew marks a race day on /board; the skipper posts a need; the crew
answers "I can"; the skipper accepts. It then checks that the crew's phone reached the skipper
only after the acceptance: it is absent from the skipper's page before, raw HTML and flight
data included, and present after in the same read and in the contact panel. Before any of that,
it fetches /support and /privacy with no session and redirects off (#147), and requires 200,
the page's own content (a streamed page answers 200 even when it fails), and on /support the
address the seed put on the club row. Last, since #220, a third browser signs up: the Sign up
tab, the seeded invite code, an email and a password, then /welcome ("Finish your profile", #219)
pre-filled with the provisional name, then the board as the name they gave. Since #226 a fourth
browser arrives by the invite link, which carries the code, and types none: first with last
season's code, where it must read the route's refusal with no account made, then with this
season's, where it must land on /welcome. Since #242 the crew's browser ends by walking the
docked nav at 390px (Post, Boats, Profile, Board) without a reload, and the tab marked as the
current screen must move with every tap. Since #207 the run ends by reading the security headers
off one response of each kind: the root, /join, the proxy's redirect for /board, /sw.js, the
manifest, the tick refusing a call with no secret, a 404 and a built chunk. Each must carry every
header with exactly its value. The job starts a local
Supabase stack from supabase/migrations, builds against it, and runs npm run smoke. It runs on
pull requests only, and timeout-minutes: 8 cancels it red past AC 3's budget.
test/smoke.test.ts holds both of those lines.
Sign-in is by password, not the admin-generated magic link #45 was filed with. #99 removed the magic link from the app, and an admin link could now reach a session only by injecting cookies around the sign-in screen (owner decision, 2026-09-21).
To run it locally, start a stack in a scratch directory (never the checkout), build against it, serve the build, then run the smoke with the same three variables set:
npx supabase init --force --with-intellij-settings=false --with-vscode-settings=false
rm -rf supabase/migrations && cp -r <tender>/supabase/migrations supabase/migrations
npx supabase start -x studio,imgproxy,edge-runtime,logflare,vector,postgres-meta,supavisor,realtime,storage-api,mailpit
# in the checkout, with NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY and
# SUPABASE_SERVICE_ROLE_KEY set to the stack's printed values:
npm run build && npx next start -p 3145 &
npm run smoke -- --db-container supabase_db_<dir> --base-url http://localhost:3145
It writes: it deletes and re-creates its two users, their race day and their boat, so it refuses
any Supabase URL that is not loopback, and its npm script loads no .env.local. A re-run starts from
the state CI does. The browser is the machine's own Chrome through playwright-core
(channel: "chrome"): nothing is downloaded, and CI's Chrome is whatever ubuntu-latest ships,
so it is not pinned. On a red run it saves each person's page to --out (CI uploads it as the
smoke-output artifact) and names the step that broke. The steps after that one print as
not reached, never as failed.
What it cannot see:
RESEND_API_KEY, so every send fails at transport construction and is
caught: the log carries one RESEND_API_KEY is not set line each for the rung, answer and match
notifications, which also shows each notify call was reached.check:live's and #48's, not this job's.Everything that decides an outcome is in scripts/smoke-core.mjs, exercised with no stack and no
browser. The steps themselves were proven against a local stack by three mutations, each red at the
predicted step and nowhere earlier: the accept step deleted from the smoke, acceptAnswer never
calling accept_answer(), and the contact policy narrowed back to self-only.
Create the Supabase project (Free; region near Ohio). Then apply every
supabase/migrations/*.sql — npm run migrate:live <file> per file once
SUPABASE_ACCESS_TOKEN is set in step 2, or the SQL editor before it is. Either route uses the
same order: numeric, except 0003 after 0004 (its functions call is_admin(),
which 0004 creates). The revoke files must be last — 0015 and 0016 — which numeric order
already gives you: they create nothing and only take privileges away from what the earlier
files created, so a table applied after them keeps the platform's default grant to anon and
the sweep never saw it. Until 0015 is
applied, npm run check:live exits 1 and names what anon can still reach — on the live
project as of 2026-08-30 that is club, answer_counts() and accept_answer(). Then seed
the club row in the SQL editor — it is a row, not a migration, so migrate:live does not
take it — which no migration seeds and without which
/api/join answers a bare 500 and nobody can sign in (measured 2026-08-23 on the live project,
whose club table was empty):
insert into public.club (name, brand_disc, brand_mark, invite_code, admin_email)
values ('Hoover Sailing Club', '#395FAC', '#FCCF0B', 'CHANGEME', '[email protected]');
admin_email is yours: the person who signs in with that address becomes the admin
(0009's trigger sets person.is_admin on their first sign-in, and on an existing person the
moment the column is set), so /admin loads with no SQL run against person. It is also
public: since #147 /support shows it to anyone, signed in or not, as the address to write
to, so use one you are content to publish. The colours are
the Hoover pair (brand/) — the club accepted them on 2026-08-22 (#11, docs/charter.md
§ Forge checks), which is why the seed carries them rather than the mark set's default green.
Changing them afterwards is /admin/theme, not an edit to this row (#41): that screen
previews the mark in the pair and shows the contrast, and set_club_theme() (0028) refuses a
pair under 3:1 at save, which a hand update would not. The code is a placeholder you rotate
from /admin once signed in.
On a project whose club row already exists, set the address on it instead:
update public.club set admin_email = '[email protected]' returning admin_email;
select display_name, is_admin from public.person where is_admin; -- your row, once signed in
2d. The race morning (#37). The same tick asks every crew still merely accepted on a match
for that day to confirm — a push if they have one, an email with a Confirm link — at the first
quarter-hour on or after 06:00 America/New_York, once (match.reminded_at). The crew's
Confirm button and the skipper's Sailed / Did not show buttons call set_match_status().
All of it is 0021. Apply 0021 before promoting the develop that carries #37. Until
then a tap on any of the three buttons is refused (PGRST202, shown as one sentence) and the
morning-of pass logs a read error and does nothing, while the ladder half still runs — neither
failure is loud, which is why the order matters. Nothing to enable and no new secret.
2e. Error email to the owner (#43). src/instrumentation.ts is Next's server error hook, and
it is the club's entire observability layer — the charter asks for "errors emailed to the
owner; nothing else", Hobby has no email alerting, and it keeps the function log for one
hour, so an error nobody reads inside that hour is an error nobody ever reads. Two things:
OWNER_EMAIL — the address the reports go to, in Vercel's environment. A ninth
server-only name beside the eight in steps 1, 2, 2b and 2c. Unset means no email at all:
the report goes to the function log with OWNER_EMAIL unset on it and expires there in an
hour, which is the same as silence. Nothing else breaks, so this failure is quiet by
construction — the one to check for deliberately rather than wait to notice.0025 before promoting the develop that carries #43. It adds
notification_log.signature, which is how "the same error again" is recognised across
Vercel's several instances. Without it the deduplication read fails and the app degrades
quietly rather than loudly: there is no 500 and no visible symptom, but the only window
left is the one held in each running instance's memory, so a cold start or a second
instance can send the same report again.0030 before promoting the develop that carries #198. It adds
error_report_claim and claim_error_report(), the one-row-per-error claim taken just
before a send, so that two instances failing in the same few seconds send one report and
not two (0025's read cannot see a send still in flight elsewhere). Without it the claim
fails and the reporter falls back to what #43 shipped, again quietly: no 500, just the
occasional duplicate.What lands: one email per distinct error — its name and the route template it threw on,
TypeError /post/[id] — at most once an hour, carrying the method, the path with any query
string removed (a reset code, an invite code and /auth/callback's PKCE code all live in
one, and this text goes to an inbox), and the first 20 lines of the stack. It rides Resend on
the same 100/day as everything else and stops at 95, like every other sender; past that the
report goes to the function log instead and notification_log records an error_skipped_cap
row.
One report is not a thrown error (#198). A refused read of the club row no longer fails the
page: it is painted in the seed pair (#395FAC / #FCCF0B) and reported as
ClubThemeReadError at loadClubTheme, whose message carries the platform's reason. The same
one-an-hour rule applies, so an outage of that read is one email, whatever the number of pages
it touched.
What it cannot see, so nobody hunts for it here: an error the app has already caught. Every
…Live wrapper in src/notify/live.ts swallows its failure to a console line on purpose, and
a swallowed error never throws out of the route, so it never reaches the hook. The same is true
of anything that fails before the app runs — a bad deployment, a DNS mistake, Vercel itself.
Confirm it after the first deployment by reading the log rather than waiting for a fault:
select sent_at, kind, signature, error from public.notification_log
where kind in ('error', 'error_skipped_cap') order by sent_at desc limit 5;
Vercel: import the repo, set the production branch to release, add the environment
variables, turn on Deployment Protection → Standard Protection (previews carry the production
Supabase host), add the domain tender.madcowsailing.com (CNAME per Vercel's per-project
target).
GitHub secrets SUPABASE_URL and SUPABASE_ANON_KEY for .github/workflows/keepalive.yml,
which reads the project once a week so Supabase Free never pauses it (7 idle days). GitHub
disables scheduled workflows after 60 days of repo inactivity — check it in spring.
git config core.hooksPath githooks in every clone. .git/ is not tracked, so this is
per-machine and per-clone; an uninstalled hook produces no error and no output, and every
symptom of its absence is an absence. Two things it does not do on its own:
core.fileMode=false on Windows means chmod +x never reaches
the index there, so the bit is set deliberately with git update-index --chmod=+x and the
check is git ls-files -s githooks reading 100755 — not ls -l, which on Windows answers
about a bit git is ignoring in both directions.git push --no-verify skips it, and this repo is public,
so the branch rules that hold against every client are GitHub's ruleset, which the provisioning
story sets up. The hook stops the habit; the ruleset stops the push.A member who wants out deletes themself — Leave the club at the foot of /profile (#42,
0027). Their profile, contact, availability, answers, messages, devices and suspension go; every
match they were on stays as a row with that side null, shown as "former member", so the season's
count is unchanged; their boats stay as ownerless names on the posts that already happened, and
an open post of theirs stays on the board until its date passes (owner decision 2026-09-20).
Removing someone else has no screen yet. delete_person() admits an admin, but the SQL
editor is not a signed-in member, so from the dashboard delete the auth user: Authentication →
Users, or delete from auth.users where id = '<uuid>'. person cascades from the auth user and
0027's rules do the rest. The send log's email addresses and device push addresses go by 0033's
person_blank_log trigger, which fires however the person row goes (#201), so there is nothing
to run first. If a member's own deletion lands on , their rows are gone
and the auth user is not — delete it under Authentication → Users. Since #204 you are emailed
when this happens: an error report named carries the auth user's id. The
leftover cannot make itself a member again: since #263 () clears the 18+
attestation with the rows, so a password reset or a Google sign-in from that address is refused
and removes the auth user itself, and only a sign-up with this season's invite code makes a new
member, as it would after a complete deletion.
brand/ holds the mark set from the 2026-08-21 brand work. The four SVGs there are the
Hoover-themed pair (#395FAC / #FCCF0B); the default-green exports the brand README's
table names were never exported. The component that was brand/TenderMark.jsx is
src/brand/TenderMark.tsx since #41 — typed, inside tsconfig, one copy — and its contrast
rule is src/brand/contrast.ts, with the same rule spelled in SQL by set_club_theme() (0028)
so it holds at save. Inline the component — never <img src> an SVG that uses the page's
colours. The app's pair is the club row's (brand_disc / brand_mark): the root layout
reads it on every request and sets --brand-disc / --brand-mark, the viewport and the manifest
from it, and the admin changes it on /admin/theme.
The product's palette, type scale, spacing scale and motion are the token layer in
src/app/globals.css (#153) — constants in the Hoover pair, not the row, because a pair an admin
may save at 3:1 cannot promise 4.5:1 to the text on it. Every colour token has a dark value, and
the pairs a surface may use are the table in test/tokens.ts: test/tokens.test.ts computes each
one in both schemes and prints the ratio, and test/reduced-motion.test.ts reads
transitionDuration in Chrome under prefers-reduced-motion. A surface that needs a new pair adds
it to the table, which adds it to the proof.
Every page renders inside the app shell, src/shell/AppShell.tsx (#154): skip link, the mark,
the signed-in person and sign-out, the navigation (docked to the bottom edge on a phone), the
page in one frame, the build stamp. A page's <main> carries no frame of its own —
test/no-inline-frame.test.ts refuses one — and data-measure="wide" is the one knob (tables).
test/shell-focus.test.ts Tabs through the shell in Chrome, prints every stop's ring colour and
ratio, and measures every target at 390px.
The six member-facing surfaces (#155) paint from data-attribute hooks in the same stylesheet —
data-stack, data-row, data-list, data-banner, data-race-date, data-badge and the rest
under the surfaces — and carry no inline style the tokens could express; the one survivor is the
rung badge's --rung / --rung-dark, which is the ladder's data. test/surfaces.test.ts renders
each surface through the real layout with fixtures (test/surfaces.ts), then reads it in Chrome
at 390px in both schemes: every text element's contrast computed and the lowest printed, the
board's rows and rules, the join tabs' selected state, and axe-core's WCAG 2.2 A/AA rules over
every surface (#229; the version is pinned and recorded in the file's header, and target-size
runs only because wcag22aa is asked for). The rung colours have a dark value each
(RUNG_COLOUR[n].dark), proven in test/rung-contrast.test.ts.
A paste is no longer the only route. With SUPABASE_ACCESS_TOKEN in .env.local, a
session can run npm run migrate:live supabase/migrations/<file>.sql, which verifies the
payload the database received before applying it — see Applying a migration above. The
ordering rules in this step still hold whichever route is used, because they are facts about
the migrations rather than about the SQL editor. Deciding to apply remains the owner's.
Put the URL, the anon key and the service-role key (SUPABASE_SERVICE_ROLE_KEY,
server-only: the invite gate reads club.invite_code and creates auth users with it) in
.env.local, and in Vercel's environment — all three: on 2026-08-23 Vercel carried only
the two public names, and /api/join threw a bare 500 ("Something went wrong.") before it
could read the club row.
That failure is no longer silent, and this step now has an instrument (#65). Every server
name below is read through env() in src/lib/env.ts, which throws <NAME> is not set —
measured on a production build: a POST to /api/join with the service-role key absent logs
⨯ Error: SUPABASE_SERVICE_ROLE_KEY is not set and answers a 500 whose body never carries the
name. And npm run check:live prints, as its first line, which of the nine server names the
shell running it has — present / ABSENT, names only and never values, so it is safe to
paste into an issue on this public repo. It reports rather than refuses, because several of
these are legitimately absent on a developer's machine.
The list lives in scripts/server-env.mjs and test/server-env.test.ts holds it equal to what
src/ actually reads, so a tenth name cannot be added to the app and forgotten here. Four of
the nine degrade instead of throwing (the VAPID pair, CRON_SECRET, OWNER_EMAIL) — they
are on the report for exactly that reason: nothing else would ever tell you.
Enable the Cron integration and confirm a job can be scheduled on this plan — ADR 004's
kill condition; its fallback is named there.
Then set the fields in the table below, under Authentication → URL Configuration / Sign In.
They are listed because a field nobody sets stays at the vendor's default permanently, and a
default leaves no wrong value to notice — Supabase ships Site URL as
http://localhost:3000, which points every confirmation email it ever sends at the recipient's
own machine. The last two share the User Signups block and its single Save changes
button, and that block has already failed to persist a flip here (#12), so reload and read
each one back after saving:
| Field | Value |
|---|---|
| Site URL | https://tender.madcowsailing.com |
| Redirect URLs | https://tender.madcowsailing.com/** and http://localhost:3000/** |
| Allow new users to sign up | ON (since #70, 2026-08-23 — it read OFF until then, and was never actually in force: the toggle did not persist, #12/#50). Tender is still invite-only, but the refusal moved out of the dashboard: a Google sign-up has to be allowed to create the auth user, so ensurePerson deletes any new auth user that arrives with no attestation and no invite gate behind it — the Google sign-in route and /auth/callback both reach it that way (src/auth/person.ts). Switching this OFF breaks Continue with Google for new members. Its cost is stray auth users, which since #85 the invite gate handles — see below |
| Allow manual linking | ON — set and read back 2026-08-24 on #74; it had been OFF, the vendor default, since the project was created. Without it Link a Google account on /profile cannot work, and a member whose Google address differs from the one they joined with has no way to be recognised. Detail, and how to check it, under Google provider below |
Check it without the dashboard: GET /auth/v1/settings reports disable_signup: false and
external.google: true, and a deliberately failing GET /auth/v1/verify?token=x redirects to
tender.madcowsailing.com rather than to localhost.
Stray auth users need nothing from you (#85). Allow new users to sign up being ON means
the public anon key can mint an auth user against any address from any browser — no
attestation, no name, no person row. On 2026-08-25 four of the project's five auth users
were exactly that, one of them belonging to a person about to be invited. Such a user used to
block that address's first sign-up: the gate saw the address was taken, dropped the name
and attestation it had just collected, sent the link anyway, and /auth/callback deleted the
user and answered "that account is not linked to a member here" — to somebody who had just
typed the right invite code. It then self-healed, because the delete cleared the address, so
the second attempt worked and there was nothing left to reproduce.
Since #85 the gate stamps its own attestation onto an unattested existing user and carries on, so the first attempt works. A wrong code or an unticked box still reaches neither the lookup nor the write. Nothing to do here, and no auth user needs deleting by hand any more.
Two things in that paragraph changed with #99, which removed the emailed link. There is no "sent the link anyway" any more, because there is no link: a sign-up creates the account, mints the person row and signs the member in on the spot. And an already-attested user is no longer left untouched and told nothing - it is somebody's account, so the sign-up answers "You already have an account here - sign in with your password" and puts them on the Sign in tab. That reveals the address is registered, deliberately: the caller has already typed this season's invite code, and the old generic sentence stopped being honest the moment no link was on its way to anybody.
Since #204 an attested user with no person row is not treated as a member. It is a sign-up
that stopped between creating the account and creating the row, for example on a refused
database read. Signing up again finishes it: the row is created from the account's own
attestation, and the typed password then signs the member in and lands them on /welcome.
Before, the member was told to sign in, then refused there, and only a password reset got
them through.
To clear the historical ones anyway — they are inert, this is tidiness rather than repair:
-- auth users with no person row, no attestation, and older than a day. The age is what keeps
-- a sign-up or a Google flow that is in progress right now out of the way: both are exactly
-- this shape for the minute or two between the auth user appearing and the link being opened.
-- The attestation is read from raw_APP_meta_data, the field only the service role writes
-- (#267). An attestation in raw_user_meta_data is one the user wrote, and does not spare them.
delete from auth.users u
where not exists (select 1 from public.person p where p.id = u.id)
and (u.raw_app_meta_data ->> 'adult_attested_at') is null
and u.created_at < now() - interval '1 day'
returning u.email, u.created_at;
That endpoint does not report every setting, and the one it is silent about is the one this
list exists for. It returns exactly eight top-level keys — external, disable_signup,
mailer_autoconfirm, phone_autoconfirm, sms_provider, saml_enabled,
saml_private_key_next_configured, passkeys_enabled — which is supabase/auth's whole
Settings struct, field for field. Manual linking is not among them. Measured 2026-08-24
in both directions: the key set is byte-identical with the setting off and with it on, so a
silent response is not evidence that it is off — which is the reading that would otherwise look
safe. Its check is below, and it is not an endpoint.
The one machine-readable route is the Management API, not the project's own: GET https://api.supabase.com/v1/projects/{ref}/config/auth carries
security_manual_linking_enabled. It needs a personal access token (sbp_…), which this app
does not hold and should not — so it is a thing the owner can run, not a probe for
check:live.
Google provider (#70, moved to the ID-token flow by #173). In Google Cloud, one OAuth client of type Web application serves all three Google flows, and it needs both of these on it:
https://tender.madcowsailing.com, and
http://localhost:3000 for next dev. Sign-in and sign-up render Google Identity Services'
button on our own page (src/auth/GoogleButton.tsx) and obtain the ID token there, and GIS
refuses to render for an origin that is not listed — the browser console says
"The given origin is not allowed for the given client ID" and the page shows no button.https://<project-ref>.supabase.co/auth/v1/callback, still —
the /profile link flow (#74) goes through it, and so does nothing else since #173.Its client id and secret go under Authentication → Providers → Google in Supabase, and
the client id ALSO goes in the app's environment as NEXT_PUBLIC_GOOGLE_CLIENT_ID, in
.env.local and in Vercel's — it is public (every page carries it), and it is what the button
renders with. Unset, /join shows no Google option on either tab and nothing says why
(npm run check:live's first line does). Leave Skip nonce checks OFF on the provider
(#70 left it off; #173 relies on it): the page hashes a fresh random nonce per render (SHA-256,
hex) and hands the hash to GIS, the route sends the raw value with signInWithIdToken, and
Supabase refuses a token whose nonce claim does not hash to it — measured, see #173.
A member whose account the email gate created and who later signs in with a Google account carrying the same verified address is linked to the existing user by Supabase (automatic identity linking) — #70's AC 6 measured it on the redirect flow, #173's AC 4 on this one. A different address is not linked, which is what Allow manual linking below is for.
The gate pass is gone. Until #173 a fifth server-only name, GATE_PASS_SECRET, signed a
ten-minute cookie that carried a new member's name and attestation across the Google
redirect to /auth/callback. The ID token now arrives in the same request as the form, so the
sign-up route checks the code, exchanges the token and mints the person row in one step, and
the cookie, the secret and the callback leg were all retired together. Remove the variable
from Vercel; nothing reads it.
What a member sees at Google, flow by flow (#77, #173). Google's screens name the
origin that asked — for the redirect flow that is the root domain of the OAuth client's
redirect URI, never the App name from consent-screen branding, which is why #77 measured
"Sign in to <project-ref>.supabase.co" on every flow with every Google-side field correct
(2026-08-23 and again 27 days on, byte-for-byte). Supabase documents it and warns it "does not
inspire trust"; their remedy, a Custom Domain, is $10/month on a paid plan and outside the $0
charter. #173 took the other route, and the three flows now differ:
/join, both tabs): the GIS popup, bound to our origin — it
names tender.madcowsailing.com (and Tender, the App name, where the popup shows one).
The exact heading text is recorded on #173./profile): still the redirect through Supabase, because
GoTrue's identity-link endpoint is redirect-only — @supabase/auth-js has no ID-token form
of linkIdentity. So this one screen still says "to continue to
<project-ref>.supabase.co", and that is not a misconfiguration; a member linking is
already signed in, which is why it was left. To look at it without granting anything,
append &prompt=consent to a hand-built /auth/v1/authorize?provider=google&redirect_to=…
URL — consent is remembered, so an ordinary attempt renders nothing — and press Cancel,
never Continue.Allow manual linking (#74) — the table's fourth row, at Authentication → Sign In / Providers → User Signups, sitting directly under Allow new users to sign up and described there as "Enable manual linking APIs for your project".
Automatic linking only ever fires on a matching verified email, so a member who joined as
[email protected] and presses Continue with Google as [email protected] is a stranger to
Supabase: a fresh auth user, deleted at the callback. With this ON they can attach that Google
account to the one they already have, from /profile, and keep one auth.uid() — which every
RLS policy in the schema is keyed on. With it OFF,
GET /auth/v1/user/identities/authorize answers 404 manual_linking_disabled
(supabase/auth's requireManualLinkingEnabled) and the app says linking is not switched on
for this club.
How to check it afterwards, given /auth/v1/settings cannot. Sign in and press Link a
Google account on /profile: it either sends you to Google (on) or returns to the profile
saying "Linking a Google account is not switched on for this club yet" (off). That is a
one-tap readout of a setting no endpoint reports, and it is deliberately a different sentence
from every other refusal so the two can never be confused.
Custom SMTP: Supabase's built-in mailer sends 2 emails an hour to team members only
(measured 2026-08-21). Point Auth → SMTP at Resend, sending from tender.madcowsailing.com;
add Resend's DNS records in the Cloudflare zone. And a Resend API key as RESEND_API_KEY
in .env.local and in Vercel's environment (server-only, a fourth name beside step 1's
three — GATE_PASS_SECRET was a fifth until #173 retired it; the Google client id under the
provider above is public, not server-only): since #23 the app sends the rung notifications itself, by Resend's REST API from
[email protected], and without the key the notification step fails before
any send — the post still stands, the failure goes to the function log, nobody is emailed
(#65 is where a missing name becomes a startup error). Both kinds of mail share Resend Free's 100/day;
the app stops at 95 of its own sends and leaves the rest for password resets. (That headroom
was sized for magic links; #99 removed them, so what it now protects is the one screen that
still emails anything - Forgot my password. The number is unchanged and is a recorded default,
not a measurement.)
2b. Web push keys (#29). Run npm run vapid:keys and put the pair it prints in
.env.local and in Vercel's environment (Production and Preview):
NEXT_PUBLIC_VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY — a sixth and seventh name beside the
five above, and the only public one in the set.
Three things about them that are not obvious:
/profile shows "Push notifications are not set up for this club yet" instead of
the control when it is missing, which is the symptom to recognise.notifyRung treats push as best-effort and logs one
warning per send when the keys are absent (ADR 007's own fallback), so a missing key is a
degradation rather than an outage — and it is silent apart from that line, which is why #65
exists.iPhones only offer push to an installed web app. A crew who has not added Tender to their
home screen will see the button and be refused by the browser; /profile says so in as many
words. That is Apple's rule, not a bug, and it is why #28 shipped before this.
2c. The ladder clock (#26). Nothing calls /api/ladder/tick until this step is done, and a
ladder that never steps down is silent rather than broken — no error, no email, a board that
simply stays on rung 1. Five things, in this order:
Enable pg_net — dashboard, Database → Extensions. Measured 2026-09-01: pg_cron
is already installed on the live project (1.6.4, left behind by #12's probe) and pg_net
is not (available at 0.20.4, never created). 0017 refuses to apply while that is
true, deliberately: with a guard on pg_cron alone it would schedule a job that calls
into a schema that does not exist, and step 5 below would still read correct.
Create the two Vault secrets — Project Settings → Vault, or in the SQL editor. The
names are fixed; 0017 reads them by name at every tick, so rotating either one later is
an update to these rows and no migration at all.
select vault.create_secret('https://tender.madcowsailing.com/api/ladder/tick', 'ladder_tick_url');
select vault.create_secret('<the same value as CRON_SECRET>', 'ladder_tick_secret');
Apply 0017 — npm run migrate:live supabase/migrations/0017_ladder_tick_schedule.sql,
or paste it. Either way it goes after 0016.
Set CRON_SECRET — any long random string (openssl rand -base64 32), the same value
as ladder_tick_secret above, in .env.local and in Vercel's environment. It is an
eighth server-only name beside the seven in steps 1, 2 and 2b. Unset refuses every
call (src/auth/bearer.ts), so a deployment that never got it is closed rather than
open — and the symptom is a uniform 401 rather than a partial success. Vercel documents
that it sends Authorization: Bearer $CRON_SECRET on its own cron invocations when that
variable is set, which is why one name serves both clocks; #27's AC 5 is where that is
measured rather than believed.
Confirm the job exists, and that it RUNS. Two reads, not one:
select jobname, schedule, active from cron.job; -- ladder-tick, */15 * * * *
select status, return_message, end_time
from cron.job_run_details order by start_time desc limit 5; -- succeeded, within 15 min
The first alone is the reassuring half. A job can be listed, active and correct while every
run fails — that is the whole reason step 1 comes first — and tick_run (0012), which
/admin renders as last tick N min ago, is the readout that does not depend on either
query being run by hand.
The daily half needs nothing here: vercel.json carries it, and Vercel picks it up on the next
production deployment. Hobby allows one invocation a day at ±59 min, which is why 0 12 * * *
is a sweep rather than the clock — pg_cron is the clock, and this is what still fires if
pg_cron wedges (ADR 004).
Is the daily sweep alive? /admin prints last daily sweep beside the tick, with its UTC
time (#145). That stamp is tick_run.sweep_at (0019), and it moves only on a request carrying
Vercel's x-vercel-cron-schedule header, so pg_cron's quarter-hour ticks do not overwrite it.
The function log cannot answer the question for long: Hobby keeps it for one hour. Apply
0019 before promoting the develop that carries it. Until then /admin's read fails and
prints never for both clocks, and the daily sweep's own write fails it with a 500.
Confirm it after the first scheduled window:
select last_at, sweep_at from public.tick_run; -- sweep_at inside 12:00–13:00 UTC today
/join?deleted=partialAuthUserNotDeleted0037delete_person()Guessing is bounded, and you should know how (#206, security-audit SA-5, 0032). A wrong
invite code at /api/join or /api/signup/google and a wrong email-and-password at
/api/signin are failures, and they count against two keys in a 15-minute window:
20 per source address, shared across all three routes so guesses cannot be spread between
them, and 10 per email address, on join and sign-in, which is what stops a guesser who
rotates addresses. /api/forgot counts every request, 20 per source address, on a budget of
its own. A caller at a limit gets the same answer a wrong code or password gets, so the limit
tells a guesser nothing. The numbers are src/auth/attempt-limit.ts's, owner decision
2026-09-23. The address limit is loose on purpose: members at the clubhouse or a regatta share
one address, and a tight limit would lock the dock out. If a whole clubhouse is locked out
anyway, it lifts on its own within 15 minutes, or at once with delete from public.auth_attempt; in the SQL editor. If the code itself may have leaked, rotate it on
/admin: the limit slows guessing and does nothing about a code that is already known.
What is stored is a SHA-256 digest of the address and the email, never either one, and every
row is deleted after the window, as /privacy says.
Supabase's own sign-in limit does not do this job. Read 2026-09-23 from the project's auth
config: rate_limit_token_refresh is 150, and the Supabase docs say password sign-in
(/auth/v1/token) falls under that limit, 150 requests per 5 minutes per IP address.
The IP it counts is the caller's, and Tender's caller is its own server: sign-in runs from a
Vercel function with the publishable key, and Supabase counts the end user's address only when
the server sends Sb-Forwarded-For with a secret key, which Tender does not. So that limit is
shared by every member signing in through the same Vercel address, and it bounds no individual
guesser. It is on, and it is left alone. (The per-IP keying is from the docs, not measured
here.)
A new password is at least 8 characters and not a common one (#268, security-audit SA-R2). Sign-up and the reset landing refuse a password that is on the common-password list, with "That password is on the lists guessers try first. Choose a different one." Sign-in never checks either rule, so no existing member is locked out when the policy moves.
1a7bb91, Passwords/Common-Credentials/100k-most-used-passwords-NCSC.txt, and was retrieved
2026-10-11. src/auth/common-passwords.json keeps every entry of 8 or more characters,
after NFC and lower-casing: 46,483 entries, about 590 KB, on the server only (it is never
sent to a browser). The comparison folds the typed password the same way, and compares the
whole password. Owner decision 2026-10-11: a bundled list rather than Have I Been Pwned's live
range check, so that sign-up makes no outside call and /privacy gains no service. Supabase's
own leaked-password check needs the Pro plan.PASSWORD_MIN in src/auth/password.ts.PASSWORD_MIN ever changes, regenerate at the new minimum:
test/common-password.test.ts holds the entry count and the shortest entry, so either change
goes red until the file matches.Every response carries the browser hardening headers (#207, security-audit SA-6):
X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, a
Permissions-Policy switching off the device features the app never uses (camera, microphone,
location, payment and the rest), and a Content-Security-Policy of frame-ancestors 'none'; object-src 'none'; base-uri 'none', so no other site can frame a page. Vercel sends HSTS
itself. The values live in src/security/headers.ts, which says what each is for, and
next.config.ts applies them to every path. There is no script policy yet; a nonce-based one
would be its own story.
src/security/headers.ts and EXPECTED_SECURITY_HEADERS in
scripts/smoke-core.mjs. test/security-headers.test.ts is red until the two agree, and the
smoke reads the served values on every pull request.Permissions-Policy in the same change. The browser refuses a blocked
feature and says so only in its console.identity-credentials-get is deliberately not on the list,
and a test holds that.