Skip to content

#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, against gen_peak_map.py's precedence
  • scripts/test_viewer_coverage.py — 21 checks, against climber_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.js reports /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

  • /request and /admin are publicly reachable by URL right now and do not work: /api/request refuses without an Access header, /api/admin answers 404 to everyone. They are in no nav and linked from nowhere. Not a hole, but worth knowing rather than discovering.
  • check_site_deploy.py does not know about /130/. It checks the root build only.
  • _NARROW_SAFE_DIRS in run_gates.py is missing docs/maps/, docs/javascripts/, docs/stylesheets/ and overrides/ — 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).