#130 Phase B — what is built, and the ten things only Kyle can do¶
2026-09-10. Written at the end of an unattended session; Phase A had landed and Kyle asked to get as far as possible on Phase B overnight.
The shape of it¶
Phase A collapsed three sites into one. Phase B makes that one site answer differently depending on who is looking, and adds the self-service door that lets a new climber in.
The front end is built, wired and verified. The back end is written but not applied — every remaining step needs an account Kyle owns, and none of them were taken unattended.
What was deliberately not done¶
No Supabase table was created or altered. No Cloudflare Access application was configured.
No Pages deployment was made. No binding was set and no secret was read, written, pasted or
echoed — including the sb_secret_ key, which peak_db's README says must never reach a chat
or a non-iCloud file.
The safety property everything else rests on¶
Absence of data is not data. Every per-viewer surface degrades to "render exactly what
the build baked" whenever identity is unavailable — nobody signed in, viewer.js absent, the
API unreachable, a climber with no list yet.
This was verified against the served site, not assumed:
| map (climbed / reported / to-do) | climber pills | |
|---|---|---|
| signed out | 463 / 141 / 33 — identical to docs/data/peaks.json |
Emily 60, Shawn 40 (the generated sidecar) |
| as Emily | 5 / 192 / 440 — her list and nothing else | Kyle 83, Shawn 84 |
| as Kyle | — | Emily 83, Shawn 83 |
The failure mode being guarded against is specific: an empty climbed list would mean "this
person has climbed nothing" and repaint 463 grey peaks red — a confident, wrong answer served
to the reader least able to catch it. So climbed: null returns the payload by identity,
and a test asserts that rather than trusting it.
The consequence worth acting on: the Phase B front-end code is already safe on the live public site. It changes nothing for an anonymous visitor. The cutover is therefore only the Access configuration — not a front-end swap.
Two implementations of one rule, held together by tests¶
Colouring a peak and bucketing a climber's coverage now exist in both Python (build) and JavaScript (browser). Nothing but a test keeps those agreeing, so both tests read the rule out of the Python source and fail if it changes shape:
scripts/test_viewer_recolor.py— 22 checks, againstgen_peak_map.py's precedencescripts/test_viewer_coverage.py— 21 checks, againstclimber_status.state_for
Both also assert the wiring, because a correct function nothing calls renders exactly like no feature at all.
The ten account steps¶
Roughly in order. Steps 1–4 are the gate; 5–7 make it useful; 8–10 are polish.
1. Apply the schema¶
supabase/schema.sql, in the peak_checklist project's SQL editor. Read the block at the
top first — it argues that profiles must not be readable with the publishable key because
it holds a home address, and the request form promises "Never shown to anyone else." If you
disagree, say so and the design loses a Function.
Then confirm by hand, once: select * from profiles; returns nothing with the publishable
key and rows with the service key. That is the whole security boundary.
2. Seed your own profile¶
One row: your email, state = 'approved', display name — and fourteeners_usernum left
NULL. That null is what stops /api/refresh scraping your public checklist and overwriting
a list that already comes from ascents. It is load-bearing and invisible, which is why it
is commented in three places.
3. Two Cloudflare Access applications, not one¶
A single policy cannot do this — it refuses the person before they can reach a form.
/request*→ admit any Google identity- everything else (or
/130/*first, while you test) → admit approved addresses only
Your constraint was explicit: "the current live pages need to be accessable without auth
until we accept and cut over." Putting Access on /130/* alone honours that — / stays
open, and /130/ is the same build, staged by scripts/deploy_site.py --stage-130.
4. Bindings on the Pages project¶
SUPABASE_URL, SUPABASE_SERVICE_KEY, OWNER_EMAILS, and for approvals CF_API_TOKEN
(Access:Edit, scoped to this one application), CF_ACCOUNT_ID, CF_ACCESS_APP_ID,
CF_ACCESS_POLICY_ID.
That API token is the one genuinely powerful secret in the design — approving someone edits the Access policy through it. Everything else in #130 avoids holding a credential, which is why this one is worth scoping tightly.
5. Deploy — and note that CI will not stage /130/ for you¶
Pushing to main already deploys, via .github/workflows/docs.yml. It calls
deploy_site.py without --stage-130, so /130/ currently 404s — deliberately,
because until step 3 attaches an Access policy to that path, staging it would just put an
unauthenticated duplicate of the site on the internet.
So when you reach this step, either run it yourself:
scripts/deploy_site.py --stage-130
or add the flag to the workflow's deploy step, which is commented with exactly this. Otherwise step 6 below will look like a broken gate when it is really an absent path.
6. Check the gate by being refused¶
Open /130/ signed out, or as an address that is not approved. If it serves, the policy is
not attached to the path you think it is. Verifying a gate by walking through it only proves
the happy path.
7. Notification webhook¶
A Supabase Database Webhook on profiles insert. It lives there rather than in
/api/request so it fires however a row arrives, not only on the happy path. You asked for a
text; the webhook can reach whatever SMS relay you prefer.
8. First real request, end to end¶
Send someone /request, approve them from /admin, and confirm two things: they can reach a
report, and their name appears in your "Still needed by" pills. The second is the
consolidation actually paying off.
9. Drive cache¶
drive_cache is in the schema and /api/viewer returns an empty drive map, so every
caller already falls back to the report's own figure. Nothing is broken until it is filled.
Carry this forward: OSRM's public server over-weights rough forest roads — it put Boulder → Clohesey Lake at 7h02m against a real ~3h. These numbers are for sorting, not for trusting; the report's directions link is what a reader should believe.
10. Then cut over¶
Move the Access application from /130/* to the site root, or leave /130/ as the staging
path indefinitely. Nothing in the code cares which.
Verified live¶
Deployed at 5bb7d7a, CI green through its own check_site_deploy.py --strict. Against
the real site, signed out:
- the map renders 463 / 141 / 33 and the pills read Emily 60 · Shawn 40 — the baked data exactly, so Phase B is inert for anonymous visitors in production and not only locally;
viewer.jsreports/cdn-cgi/access/get-identity → 404, which is the correct answer until step 3 exists, and it degrades rather than failing;/emily/,/shawn/and/shareable/all redirect to/;- the climber switcher is gone.
Loose ends, honestly¶
/requestand/adminare publicly reachable by URL right now and do not work:/api/requestrefuses without an Access header,/api/adminanswers 404 to everyone. They are in no nav and linked from nowhere. Not a hole, but worth knowing rather than discovering.check_site_deploy.pydoes not know about/130/. It checks the root build only._NARROW_SAFE_DIRSinrun_gates.pyis missingdocs/maps/,docs/javascripts/,docs/stylesheets/andoverrides/— so a change to those still runs the wide sweep. It is a missed optimisation, not a correctness bug, and it is why tonight's pushes were slow.- Open issues: #135 (compliance counts report-less slugs), #137 (jacque_peak beta to re-derive), #138 (every report needs a share — blocked on three reports with no recommended GPX).